Compare commits

...
3 Commits
Author SHA1 Message Date
sakshamandClaude Sonnet 5 8ec7ca0881 Restart nginx on every deploy to avoid stale upstream IPs
nginx resolves the api/web service names to a container IP once, at
its own worker-process startup. docker compose up -d only recreates
containers whose image/config changed, so nginx keeps proxying to the
old, now-dead IP after a deploy rebuilds those containers - every
request 502s, which shows up in the browser as a misleading CORS
error since the bare 502 carries no Access-Control-Allow-Origin
header. Confirmed live: this caused a real multi-hour production
outage after the last two deploys.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-06 07:07:55 -04:00
sakshamandClaude Sonnet 5 4ee38b6241 Add DB viewer access logging, account deletion, and forced password change
Logs a distinct db_viewer_accessed event (not just the earlier
session_created "requested" event) when an admin's browser actually
completes the hand-off into Adminer. Adds a password-confirmed
account-deletion box to Settings, relying on the existing ON DELETE
CASCADE foreign keys to clean up everything the account owns. Adds an
admin-only "require password change" flag that get_current_user
enforces server-side (403 on everything except /auth/me,
/auth/change-password, /auth/logout) - meant for handing a demo
account to someone with a known sample password.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 23:41:21 -04:00
sakshamandClaude Sonnet 5 3d6fe56991 Add Settings -> Database viewer (Adminer) for local devs and any admin
Local dev gets an unauthenticated Adminer instance bound to loopback
only. In production, any account with is_admin=true can open it -
the app mints a short-lived token from a live admin session, which
Nginx's new db.ciagent.org block exchanges for a session cookie that
re-checks admin status on every request, instead of a shared static
password that wouldn't scale to multiple admins or revoke live.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 22:18:16 -04:00
33 changed files with 1697 additions and 10 deletions
+8
View File
@@ -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 # this if you run your own self-hosted git server and want the button to
# point at your fork instead of upstream. # point at your fork instead of upstream.
NEXT_PUBLIC_GIT_REPO_URL= 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) ----- # --- Reverse proxy (only relevant once deployed behind Cloudflare/Nginx) -----
# Empty = trust the direct connection for client-IP resolution (correct for # Empty = trust the direct connection for client-IP resolution (correct for
+21 -6
View File
@@ -13,6 +13,7 @@ One Docker host runs everything via `docker-compose.prod.yml`: Postgres, Redis,
| `ciagent.org` | `web:3000` | Next.js frontend | | `ciagent.org` | `web:3000` | Next.js frontend |
| `api.ciagent.org` | `api:8000` | FastAPI backend | | `api.ciagent.org` | `api:8000` | FastAPI backend |
| `git.ciagent.org` | `gitea:3000` | Self-hosted git (public read, admin-only write) | | `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`. 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 ## 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. 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 three subdomains), leave the default 15-year validity. Save the cert and private key. 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. 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. 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 BACKEND_URL=https://api.ciagent.org
NEXT_PUBLIC_API_URL=https://api.ciagent.org NEXT_PUBLIC_API_URL=https://api.ciagent.org
NEXT_PUBLIC_GIT_REPO_URL=https://git.ciagent.org/<your-admin-username>/ci-agent NEXT_PUBLIC_GIT_REPO_URL=https://git.ciagent.org/<your-admin-username>/ci-agent
NEXT_PUBLIC_DB_VIEWER_URL=https://db.ciagent.org
TRUSTED_PROXY_IP_HEADER=CF-Connecting-IP TRUSTED_PROXY_IP_HEADER=CF-Connecting-IP
RESEND_API_KEY=<real Resend key> # no admin-UI equivalent for this one RESEND_API_KEY=<real Resend 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/<you>/ci-agent.git` or the web UI directly. Read-only clone/browse works for anyone, no account: `https://git.ciagent.org/<you>/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: `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/<y
Also set `LLM_PROVIDER=anthropic` and `SEARCH_PROVIDER=brave` in `.env` (restart `api`/`worker`/`beat` after) once you've configured the corresponding keys above — provider *selection* is still a deployment-level `.env` setting, only the key *values* moved to the UI. Also set `LLM_PROVIDER=anthropic` and `SEARCH_PROVIDER=brave` in `.env` (restart `api`/`worker`/`beat` after) once you've configured the corresponding keys above — provider *selection* is still a deployment-level `.env` setting, only the key *values* moved to the UI.
## 8. Updating the deployment ## 9. Updating the deployment
Manually: Manually:
@@ -115,8 +127,11 @@ git pull
docker compose -f docker-compose.prod.yml build api worker beat web docker compose -f docker-compose.prod.yml build api worker beat web
docker compose -f docker-compose.prod.yml run --rm api alembic upgrade head docker compose -f docker-compose.prod.yml run --rm api alembic upgrade head
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml restart nginx
``` ```
**The `restart nginx` step is not optional.** `up -d` only recreates the containers whose image/config changed - nginx's own image/config doesn't change on an app deploy, so it keeps running with the container IPs it resolved at its *own* last startup. Once `api`/`web` get rebuilt with new IPs, nginx keeps proxying to the old, now-dead ones until something forces it to re-resolve - every request 502s (and shows up in the browser as a misleading CORS error, since a bare 502 from nginx carries no `Access-Control-Allow-Origin` header). Confirmed live: this caused a real multi-hour outage. `scripts/auto-deploy.sh` includes this step automatically.
**Or automatically**: `scripts/auto-deploy.sh` runs exactly that sequence, gated on "is `origin/master` ahead of `HEAD`" so it's a no-op most runs. Install it as a systemd timer (polls every 2 minutes — deliberately polling, not a Gitea webhook, so there's no extra exposed service, no Docker-socket-in-a-container, and no shared secret to manage): **Or automatically**: `scripts/auto-deploy.sh` runs exactly that sequence, gated on "is `origin/master` ahead of `HEAD`" so it's a no-op most runs. Install it as a systemd timer (polls every 2 minutes — deliberately polling, not a Gitea webhook, so there's no extra exposed service, no Docker-socket-in-a-container, and no shared secret to manage):
```bash ```bash
@@ -128,7 +143,7 @@ systemctl enable --now ci-agent-deploy.timer
Once running, pushing to `master` on Gitea is enough — the server picks it up within ~2 minutes, no manual SSH step needed. Check `journalctl -u ci-agent-deploy.service` to see deploy runs. Once running, pushing to `master` on Gitea is enough — the server picks it up within ~2 minutes, no manual SSH step needed. Check `journalctl -u ci-agent-deploy.service` to see deploy runs.
## 9. Backups ## 10. Backups
Nothing backs itself up by default. At minimum, a nightly cron job on the host: Nothing backs itself up by default. At minimum, a nightly cron job on the host:
@@ -142,7 +157,7 @@ docker run --rm -v ci-agent-prod_gitea-data:/data -v /backups:/backup alpine \
Copy `/backups` off-box (a Hetzner Storage Box via `rclone`, or any object storage) — local-only backups don't survive a lost disk. Retain a sane number of days and prune older ones. Copy `/backups` off-box (a Hetzner Storage Box via `rclone`, or any object storage) — local-only backups don't survive a lost disk. Retain a sane number of days and prune older ones.
## 10. Operational notes ## 11. Operational notes
- `beat` must stay exactly one instance, always — duplicate scheduled monitoring runs otherwise. Don't `--scale beat=2`. - `beat` must stay exactly one instance, always — duplicate scheduled monitoring runs otherwise. Don't `--scale beat=2`.
- Docker's log driver is capped per-service (`max-size: 10m`, `max-file: 3` in `docker-compose.prod.yml`) — nothing else bounds log growth on the host. - Docker's log driver is capped per-service (`max-size: 10m`, `max-file: 3` in `docker-compose.prod.yml`) — nothing else bounds log growth on the host.
+7
View File
@@ -178,4 +178,11 @@ This file is updated as each phase lands. It exists so nothing is silently claim
- **New `user_known_ips` table captures every distinct IP an account has signed in from** (`app/models/user_known_ip.py`), one row per `(user_id, ip_address)` pair with `first_seen_at`/`last_seen_at`, updated on every recorded sign-in (both real login and the local-dev bypass, gated by the same cooldown above for the latter). This is pure data capture for now - **nothing currently reads this table or surfaces it anywhere in the UI**; it exists as the foundation for a possible future "new device/location" security feature, per explicit request. `UserKnownIpRepository.record_login`'s return value (whether the IP was new) is already threaded through but currently unused by any caller. - **New `user_known_ips` table captures every distinct IP an account has signed in from** (`app/models/user_known_ip.py`), one row per `(user_id, ip_address)` pair with `first_seen_at`/`last_seen_at`, updated on every recorded sign-in (both real login and the local-dev bypass, gated by the same cooldown above for the latter). This is pure data capture for now - **nothing currently reads this table or surfaces it anywhere in the UI**; it exists as the foundation for a possible future "new device/location" security feature, per explicit request. `UserKnownIpRepository.record_login`'s return value (whether the IP was new) is already threaded through but currently unused by any caller.
- **IPs were already being recorded per-login inside `user_security_events`** (every `login_success`/`login_failed` row has always carried `ip_address`) - `user_known_ips` doesn't replace that, it's a deliberately separate, deduplicated view: the security-events log is an append-only history of every attempt, while `user_known_ips` answers "what's the current set of IPs this account has ever used" without needing to scan and dedupe the (much larger, unbounded-growth - see the Phase 19 addendum above) events table. - **IPs were already being recorded per-login inside `user_security_events`** (every `login_success`/`login_failed` row has always carried `ip_address`) - `user_known_ips` doesn't replace that, it's a deliberately separate, deduplicated view: the security-events log is an append-only history of every attempt, while `user_known_ips` answers "what's the current set of IPs this account has ever used" without needing to scan and dedupe the (much larger, unbounded-growth - see the Phase 19 addendum above) events table.
## Settings -> 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. Further limitations are appended per-phase below.
+19 -1
View File
@@ -15,13 +15,14 @@ from app.core.security import get_client_ip
from app.db.session import get_db from app.db.session import get_db
from app.models.user import User from app.models.user import User
from app.repositories.unban_request_repository import UnbanRequestRepository from app.repositories.unban_request_repository import UnbanRequestRepository
from app.schemas.auth import RequirePasswordChangeRequest
from app.schemas.unban import ( from app.schemas.unban import (
BanIpRequest, BanIpRequest,
IpBanResponse, IpBanResponse,
UnbanRequestPayload, UnbanRequestPayload,
UnbanRequestResponse, UnbanRequestResponse,
) )
from app.services import unban_service from app.services import auth_service, unban_service
router = APIRouter(tags=["admin"]) router = APIRouter(tags=["admin"])
@@ -89,3 +90,20 @@ async def reject_unban_request(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
) -> None: ) -> None:
await unban_service.reject_unban_request(db, request_id) await unban_service.reject_unban_request(db, request_id)
@router.post("/admin/users/require-password-change", status_code=status.HTTP_204_NO_CONTENT)
async def require_password_change(
request: Request,
payload: RequirePasswordChangeRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> None:
"""Flags an account (by email) so its next request is blocked
everywhere except /auth/me, /auth/change-password, and /auth/logout
until they set a new password - see app.auth.dependencies.get_current_user
and app.services.auth_service.require_password_change. Meant for handing
a demo account to someone with a known sample password."""
client_ip = get_client_ip(request, settings)
await auth_service.require_password_change(db, client_ip, payload.email)
+23
View File
@@ -14,7 +14,9 @@ from app.core.security import get_client_ip, is_localhost
from app.db.session import get_db from app.db.session import get_db
from app.models.user import LOCAL_DEV_USER_ID, User from app.models.user import LOCAL_DEV_USER_ID, User
from app.schemas.auth import ( from app.schemas.auth import (
ChangePasswordRequest,
ConfirmPasswordResetRequest, ConfirmPasswordResetRequest,
DeleteAccountRequest,
LoginRequest, LoginRequest,
LogoutRequest, LogoutRequest,
RefreshRequest, RefreshRequest,
@@ -166,3 +168,24 @@ async def me(user: User = Depends(get_current_user)) -> MeResponse:
effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt" effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt"
base = UserResponse.model_validate(user).model_dump() base = UserResponse.model_validate(user).model_dump()
return MeResponse(**base, auth_mode=effective_auth_mode) return MeResponse(**base, auth_mode=effective_auth_mode)
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_account(
payload: DeleteAccountRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
await auth_service.delete_account(db, user, payload.password)
@router.post("/change-password", response_model=TokenResponse)
async def change_password(
request: Request,
payload: ChangePasswordRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> TokenResponse:
client_ip = get_client_ip(request, settings)
return await auth_service.change_password(db, settings, client_ip, user, payload)
+146
View File
@@ -0,0 +1,146 @@
"""Settings -> Database viewer: lets any admin open a web-based Postgres
client (Adminer, at db.<domain>) 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.<domain> /_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.<domain> 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://<db-viewer-host>/_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(
request: Request,
token: str = Query(...),
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> RedirectResponse:
"""Reached only via Nginx's db.<domain> /_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
# Logged here, not at /session mint time, because this is the point the
# admin's browser actually completes the hand-off into Adminer - minting
# a token only proves they clicked the button, not that they got in.
await UserSecurityEventRepository(db).create(
user_id=decoded.user_id,
event_type=SecurityEventType.DB_VIEWER_ACCESSED,
ip_address=get_client_ip(request, settings),
)
await db.commit()
session_token = create_db_viewer_session_token(decoded.user_id, settings)
# Adminer's own driver dropdown defaults to MySQL, not Postgres - a bare
# "/" redirect leaves it selecting MySQL against a host that only speaks
# Postgres, producing a "Connection refused" error that has nothing to
# do with this app's own auth. `?pgsql=postgres` pre-selects the right
# driver and server (the internal Compose service name, same in dev and
# prod) - only the Postgres password itself is left for the admin to type.
response = RedirectResponse(url="/?pgsql=postgres", 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.<domain>), 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)
+2
View File
@@ -11,6 +11,7 @@ from app.api.v1 import (
auth, auth,
companies, companies,
dashboard, dashboard,
db_viewer,
monitoring, monitoring,
notification_destinations, notification_destinations,
reports, reports,
@@ -24,6 +25,7 @@ api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(system.router) api_v1_router.include_router(system.router)
api_v1_router.include_router(auth.router) api_v1_router.include_router(auth.router)
api_v1_router.include_router(admin.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(user_api_keys.router)
api_v1_router.include_router(companies.router) api_v1_router.include_router(companies.router)
api_v1_router.include_router(notification_destinations.router) api_v1_router.include_router(notification_destinations.router)
+14
View File
@@ -29,6 +29,15 @@ from app.models.user import User
from app.repositories.user_repository import UserRepository from app.repositories.user_repository import UserRepository
from app.services.auth_service import get_or_create_local_user from app.services.auth_service import get_or_create_local_user
# Everything an account with must_change_password=True can still reach -
# just enough to discover the flag (/me), fix it (/change-password), and
# bail out (/logout, which doesn't even route through get_current_user but
# is listed for clarity). Every other endpoint 403s until they change it -
# see app.services.auth_service.require_password_change.
_PASSWORD_CHANGE_EXEMPT_PATHS = frozenset(
{"/api/v1/auth/me", "/api/v1/auth/change-password", "/api/v1/auth/logout"}
)
async def get_current_user( async def get_current_user(
request: Request, request: Request,
@@ -58,6 +67,11 @@ async def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired access token", detail="Invalid or expired access token",
) )
if user.must_change_password and request.url.path not in _PASSWORD_CHANGE_EXEMPT_PATHS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Password change required",
)
return user return user
+13
View File
@@ -75,6 +75,11 @@ def verify_password(raw_password: str, password_hash: str) -> bool:
class TokenType(StrEnum): class TokenType(StrEnum):
ACCESS = "access" ACCESS = "access"
REFRESH = "refresh" 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) @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 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( def _encode_token(
user_id: uuid.UUID, user_id: uuid.UUID,
token_type: TokenType, token_type: TokenType,
+4
View File
@@ -153,6 +153,10 @@ class SecurityEventType(StrEnum):
EMAIL_VERIFIED = "email_verified" EMAIL_VERIFIED = "email_verified"
SERVER_SECRET_UPDATED = "server_secret_updated" SERVER_SECRET_UPDATED = "server_secret_updated"
API_KEY_UPDATED = "api_key_updated" API_KEY_UPDATED = "api_key_updated"
DB_VIEWER_SESSION_CREATED = "db_viewer_session_created"
DB_VIEWER_ACCESSED = "db_viewer_accessed"
PASSWORD_CHANGED = "password_changed"
PASSWORD_CHANGE_REQUIRED = "password_change_required"
class ApiKeyProvider(StrEnum): class ApiKeyProvider(StrEnum):
+6
View File
@@ -42,6 +42,12 @@ class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
failed_login_count: Mapped[int] = mapped_column(Integer, default=0) failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Admin-forced reset (e.g. before handing a demo account to someone) -
# see app.auth.dependencies.get_current_user, which blocks every
# endpoint except /auth/me, /auth/change-password, and /auth/logout
# while this is true.
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
refresh_tokens: Mapped[list[RefreshToken]] = relationship( refresh_tokens: Mapped[list[RefreshToken]] = relationship(
back_populates="user", cascade="all, delete-orphan" back_populates="user", cascade="all, delete-orphan"
) )
@@ -23,6 +23,13 @@ class UserRepository:
result = await self.db.execute(select(User.email).where(User.is_admin.is_(True))) result = await self.db.execute(select(User.email).where(User.is_admin.is_(True)))
return list(result.scalars().all()) return list(result.scalars().all())
async def delete(self, user: User) -> None:
"""Cascades (ON DELETE CASCADE, see migrations) to every row the
user owns - companies and everything under them, refresh tokens,
security events, etc. Irreversible."""
await self.db.delete(user)
await self.db.flush()
async def create( async def create(
self, self,
*, *,
+18
View File
@@ -73,6 +73,24 @@ class ConfirmPasswordResetRequest(BaseModel):
return _validate_password_strength(value) return _validate_password_strength(value)
class DeleteAccountRequest(BaseModel):
password: str = Field(min_length=1, max_length=128)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=10, max_length=128)
@field_validator("new_password")
@classmethod
def _password_strength(cls, value: str) -> str:
return _validate_password_strength(value)
class RequirePasswordChangeRequest(BaseModel):
email: EmailStr
class SecurityEventResponse(BaseModel): class SecurityEventResponse(BaseModel):
event_type: str event_type: str
ip_address: str ip_address: str
+1
View File
@@ -14,6 +14,7 @@ class UserResponse(BaseModel):
timezone: str timezone: str
is_active: bool is_active: bool
is_admin: bool is_admin: bool
must_change_password: bool
class MeResponse(UserResponse): class MeResponse(UserResponse):
+80 -1
View File
@@ -13,7 +13,13 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings from app.core.config import Settings
from app.core.errors import AuthenticationError, ConflictError, ThrottledError, ValidationAppError from app.core.errors import (
AuthenticationError,
ConflictError,
NotFoundError,
ThrottledError,
ValidationAppError,
)
from app.core.security import ( from app.core.security import (
InvalidTokenError, InvalidTokenError,
TokenType, TokenType,
@@ -37,6 +43,7 @@ from app.repositories.user_known_ip_repository import UserKnownIpRepository
from app.repositories.user_repository import UserRepository from app.repositories.user_repository import UserRepository
from app.repositories.user_security_event_repository import UserSecurityEventRepository from app.repositories.user_security_event_repository import UserSecurityEventRepository
from app.schemas.auth import ( from app.schemas.auth import (
ChangePasswordRequest,
ConfirmPasswordResetRequest, ConfirmPasswordResetRequest,
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
@@ -426,3 +433,75 @@ async def list_security_events(db: AsyncSession, user_id: uuid.UUID) -> list[Use
"""The calling user's own security activity - the user-facing """The calling user's own security activity - the user-facing
counterpart to the admin-only app-wide log feed (core/logging.py).""" counterpart to the admin-only app-wide log feed (core/logging.py)."""
return await UserSecurityEventRepository(db).list_for_user(user_id) return await UserSecurityEventRepository(db).list_for_user(user_id)
async def delete_account(db: AsyncSession, user: User, password: str) -> None:
"""Permanently deletes the account and, via ON DELETE CASCADE foreign
keys (see migrations), everything it owns - companies and everything
under them, refresh tokens, security events, API keys, etc. There is no
soft-delete or recovery path. Rejected for accounts with no
password_hash (the fixed AUTH_MODE=local user) - there's nothing to
verify the caller's identity against."""
if user.password_hash is None:
raise ValidationAppError("Account deletion isn't available for this account.")
if not verify_password(password, user.password_hash):
raise AuthenticationError("Incorrect password")
await UserRepository(db).delete(user)
await db.commit()
async def change_password(
db: AsyncSession,
settings: Settings,
client_ip: str,
user: User,
payload: ChangePasswordRequest,
) -> TokenResponse:
"""Authenticated in-app password change - distinct from
confirm_password_reset (which is the emailed-code flow for someone who
can't log in at all). Also the only way to clear must_change_password,
the admin-forced-reset flag (see require_password_change below)."""
if user.password_hash is None:
raise ValidationAppError("This account doesn't use password sign-in.")
if not verify_password(payload.current_password, user.password_hash):
raise AuthenticationError("Incorrect current password")
history_repo = PasswordHistoryRepository(db)
previous_hashes = await history_repo.list_hashes_for_user(user.id)
previous_hashes.append(user.password_hash)
if any(verify_password(payload.new_password, h) for h in previous_hashes):
raise ValidationAppError("You've used this password before. Choose a different one.")
await history_repo.add(user_id=user.id, password_hash=user.password_hash)
user.password_hash = hash_password(payload.new_password)
user.must_change_password = False
# Rotate every session, including the one making this request - the
# fresh token pair returned below replaces it immediately, so the
# caller keeps working without a forced re-login.
await RefreshTokenRepository(db).revoke_all_for_user(user.id)
await UserSecurityEventRepository(db).create(
user_id=user.id, event_type=SecurityEventType.PASSWORD_CHANGED, ip_address=client_ip
)
return await _issue_token_pair(db, settings, user)
async def require_password_change(db: AsyncSession, client_ip: str, email: str) -> User:
"""Admin action: flags an account so its next request is blocked
everywhere except /auth/me, /auth/change-password, and /auth/logout
(enforced in app.auth.dependencies.get_current_user) until they set a
new password. Meant for handing a demo account to someone with a known
sample password."""
user = await UserRepository(db).get_by_email(email)
if user is None:
raise NotFoundError("No account with that email")
if user.password_hash is None:
raise ValidationAppError("This account doesn't use password sign-in.")
user.must_change_password = True
await UserSecurityEventRepository(db).create(
user_id=user.id,
event_type=SecurityEventType.PASSWORD_CHANGE_REQUIRED,
ip_address=client_ip,
)
await db.commit()
return user
@@ -0,0 +1,32 @@
"""add must_change_password to users
Revision ID: 9e6c80c11da7
Revises: c34c769afc07
Create Date: 2026-08-06 03:10:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "9e6c80c11da7"
down_revision: str | None = "c34c769afc07"
branch_labels: Sequence[str] | str | None = None
depends_on: Sequence[str] | str | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"must_change_password", sa.Boolean(), nullable=False, server_default=sa.false()
),
)
def downgrade() -> None:
op.drop_column("users", "must_change_password")
@@ -0,0 +1,260 @@
"""Account deletion (DELETE /auth/me) and admin-forced password change
(POST /admin/users/require-password-change -> POST /auth/change-password,
enforced by app.auth.dependencies.get_current_user)."""
from __future__ import annotations
import uuid
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import SecurityEventType
from app.repositories.user_repository import UserRepository
from app.repositories.user_security_event_repository import UserSecurityEventRepository
def _unique_email() -> str:
return f"user-{uuid.uuid4().hex[:12]}@example.com"
def _register_and_login(client: TestClient, password: str = "correct-horse-1") -> dict:
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": password, "display_name": "T"},
)
tokens = client.post("/api/v1/auth/login", json={"email": email, "password": password}).json()
return {"email": email, "headers": {"Authorization": f"Bearer {tokens['access_token']}"}}
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']}"}
# --- DELETE /auth/me -------------------------------------------------------
def test_delete_account_requires_auth(client: TestClient):
resp = client.request(
"DELETE", "/api/v1/auth/me", json={"password": "whatever"}
)
assert resp.status_code == 401
def test_delete_account_rejects_wrong_password(client: TestClient):
account = _register_and_login(client)
resp = client.request(
"DELETE",
"/api/v1/auth/me",
json={"password": "not-the-right-password"},
headers=account["headers"],
)
assert resp.status_code == 401
def test_delete_account_succeeds_with_correct_password_and_logs_out_the_account(
client: TestClient,
):
account = _register_and_login(client)
resp = client.request(
"DELETE",
"/api/v1/auth/me",
json={"password": "correct-horse-1"},
headers=account["headers"],
)
assert resp.status_code == 204
# The account is gone - the same token no longer resolves to anyone.
me_resp = client.get("/api/v1/auth/me", headers=account["headers"])
assert me_resp.status_code == 401
# And a fresh login attempt with the same credentials fails too.
login_resp = client.post(
"/api/v1/auth/login",
json={"email": account["email"], "password": "correct-horse-1"},
)
assert login_resp.status_code == 401
async def test_delete_account_cascades_to_owned_data(client: TestClient, db_session: AsyncSession):
"""A company created by the account must be gone too (ON DELETE CASCADE),
not just the user row."""
account = _register_and_login(client)
create_resp = client.post(
"/api/v1/companies",
json={"name": f"Co-{uuid.uuid4().hex[:8]}", "official_website": None},
headers=account["headers"],
)
assert create_resp.status_code == 201
company_id = create_resp.json()["id"]
client.request(
"DELETE", "/api/v1/auth/me", json={"password": "correct-horse-1"}, headers=account["headers"]
)
from sqlalchemy import text
row = await db_session.execute(
text("SELECT 1 FROM companies WHERE id = :id"), {"id": company_id}
)
assert row.first() is None
# --- POST /admin/users/require-password-change -----------------------------
def test_require_password_change_requires_admin(client: TestClient):
account = _register_and_login(client)
resp = client.post(
"/api/v1/admin/users/require-password-change",
json={"email": account["email"]},
headers=account["headers"],
)
assert resp.status_code == 403
async def test_require_password_change_rejects_unknown_email(
client: TestClient, db_session: AsyncSession
):
headers = await _register_admin_and_login(client, db_session)
resp = client.post(
"/api/v1/admin/users/require-password-change",
json={"email": "[email protected]"},
headers=headers,
)
assert resp.status_code == 404
async def test_admin_flags_an_account_and_it_gets_logged_to_the_targets_activity(
client: TestClient, db_session: AsyncSession
):
"""Checked directly against the DB, not via GET /auth/security-events -
that endpoint isn't in the must-change-password exempt list, so the
just-flagged account can't reach it until they change their password
(see test_flagged_account_is_blocked_from_other_endpoints)."""
admin_headers = await _register_admin_and_login(client, db_session)
account = _register_and_login(client)
resp = client.post(
"/api/v1/admin/users/require-password-change",
json={"email": account["email"]},
headers=admin_headers,
)
assert resp.status_code == 204
user = await UserRepository(db_session).get_by_email(account["email"])
assert user.must_change_password is True
events = await UserSecurityEventRepository(db_session).list_for_user(user.id)
assert any(e.event_type == SecurityEventType.PASSWORD_CHANGE_REQUIRED for e in events)
# --- Enforcement gate: everything except me/change-password/logout 403s ----
async def test_flagged_account_is_blocked_from_other_endpoints(
client: TestClient, db_session: AsyncSession
):
admin_headers = await _register_admin_and_login(client, db_session)
account = _register_and_login(client)
client.post(
"/api/v1/admin/users/require-password-change",
json={"email": account["email"]},
headers=admin_headers,
)
blocked = client.get("/api/v1/companies", headers=account["headers"])
assert blocked.status_code == 403
still_ok = client.get("/api/v1/auth/me", headers=account["headers"])
assert still_ok.status_code == 200
assert still_ok.json()["must_change_password"] is True
async def test_change_password_clears_the_flag_and_unblocks_the_account(
client: TestClient, db_session: AsyncSession
):
admin_headers = await _register_admin_and_login(client, db_session)
account = _register_and_login(client)
client.post(
"/api/v1/admin/users/require-password-change",
json={"email": account["email"]},
headers=admin_headers,
)
change_resp = client.post(
"/api/v1/auth/change-password",
json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"},
headers=account["headers"],
)
assert change_resp.status_code == 200
new_tokens = change_resp.json()
new_headers = {"Authorization": f"Bearer {new_tokens['access_token']}"}
me_resp = client.get("/api/v1/auth/me", headers=new_headers)
assert me_resp.status_code == 200
assert me_resp.json()["must_change_password"] is False
unblocked = client.get("/api/v1/companies", headers=new_headers)
assert unblocked.status_code == 200
# The new password actually works on a fresh login.
login_resp = client.post(
"/api/v1/auth/login",
json={"email": account["email"], "password": "brand-new-horse-2"},
)
assert login_resp.status_code == 200
def test_change_password_rejects_wrong_current_password(client: TestClient):
account = _register_and_login(client)
resp = client.post(
"/api/v1/auth/change-password",
json={"current_password": "totally-wrong", "new_password": "brand-new-horse-2"},
headers=account["headers"],
)
assert resp.status_code == 401
def test_change_password_rejects_reusing_the_current_password(client: TestClient):
account = _register_and_login(client)
resp = client.post(
"/api/v1/auth/change-password",
json={"current_password": "correct-horse-1", "new_password": "correct-horse-1"},
headers=account["headers"],
)
assert resp.status_code == 400
def test_change_password_revokes_the_old_refresh_token(client: TestClient):
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()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
client.post(
"/api/v1/auth/change-password",
json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"},
headers=headers,
)
stale_refresh_resp = client.post(
"/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]}
)
assert stale_refresh_resp.status_code == 401
+219
View File
@@ -0,0 +1,219 @@
"""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"] == "/?pgsql=postgres"
assert "db_viewer_session" in resp.headers["set-cookie"]
assert "HttpOnly" in resp.headers["set-cookie"]
assert "Secure" in resp.headers["set-cookie"]
async def test_bootstrap_success_is_logged_to_the_admins_account_activity(
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"]
client.get("/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False)
events = client.get("/api/v1/auth/security-events", headers=headers).json()
assert any(e["event_type"] == "db_viewer_accessed" for e in events)
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
)
+18
View File
@@ -15,6 +15,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const logout = useLogout(); const logout = useLogout();
const requiresLogin = systemStatus ? !isLocalConvenience(systemStatus) : false; const requiresLogin = systemStatus ? !isLocalConvenience(systemStatus) : false;
const mustChangePassword = user?.must_change_password ?? false;
useEffect(() => { useEffect(() => {
if (requiresLogin && !isLoading && isError) { if (requiresLogin && !isLoading && isError) {
@@ -22,6 +23,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
} }
}, [requiresLogin, isLoading, isError, router]); }, [requiresLogin, isLoading, isError, router]);
useEffect(() => {
if (mustChangePassword) {
router.replace("/change-password");
}
}, [mustChangePassword, router]);
if (requiresLogin && (isLoading || isError)) { if (requiresLogin && (isLoading || isError)) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
@@ -30,6 +37,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
); );
} }
// Every API call except /auth/me, /auth/change-password, and /auth/logout
// 403s server-side while this is set (app/auth/dependencies.py) - this is
// just the matching frontend redirect, not the actual enforcement.
if (mustChangePassword) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
Redirecting
</div>
);
}
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50">
<LocalModeBanner /> <LocalModeBanner />
+120
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import { import {
Bell, Bell,
CheckCircle2, CheckCircle2,
Database,
KeyRound, KeyRound,
Loader2, Loader2,
Mail, Mail,
@@ -24,8 +25,10 @@ import {
import { authErrorMessage } from "@/hooks/use-auth"; import { authErrorMessage } from "@/hooks/use-auth";
import { import {
useAcceptUnbanRequest, useAcceptUnbanRequest,
useCreateDbViewerSession,
useCreateIpBan, useCreateIpBan,
useCurrentUser, useCurrentUser,
useDeleteAccount,
useDeleteIpBan, useDeleteIpBan,
useIpBans, useIpBans,
useRejectUnbanRequest, useRejectUnbanRequest,
@@ -48,6 +51,7 @@ import { FormField } from "@/components/ui/form-field";
import { Select } from "@/components/ui/select"; import { Select } from "@/components/ui/select";
import { SystemSecretRow } from "@/components/ui/system-secret-row"; import { SystemSecretRow } from "@/components/ui/system-secret-row";
import { UserApiKeyRow } from "@/components/ui/user-api-key-row"; import { UserApiKeyRow } from "@/components/ui/user-api-key-row";
import { isLocalConvenience } from "@/lib/auth";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
import { import {
SEVERITY_LABELS, SEVERITY_LABELS,
@@ -663,6 +667,119 @@ function IpBansBox() {
); );
} }
function DatabaseViewerBox() {
const { data: systemStatus } = useSystemStatus();
const createSession = useCreateDbViewerSession();
const [error, setError] = useState<string | null>(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 (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<Database className="h-4 w-4" aria-hidden /> Database
</div>
<p className="mt-1 text-sm text-slate-500">
Open a web-based Postgres client (Adminer) in a new tab to view and edit rows directly.
PostgreSQL is pre-selected - just enter the database password to sign in. Local developers
and server admins only.
</p>
<div className="mt-4">
{isLocal ? (
<a
href="http://localhost:8081/?pgsql=postgres&username=ciagent"
target="_blank"
rel="noopener noreferrer"
className="focus-ring inline-flex items-center gap-1.5 rounded-md bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white transition-colors duration-200 hover:bg-slate-900"
>
<Database className="h-3.5 w-3.5" aria-hidden /> Open database viewer
</a>
) : dbViewerUrl ? (
<ActionButton
onClick={handleOpen}
pending={createSession.isPending}
colorClass="bg-slate-800 hover:bg-slate-900"
icon={Database}
label="Open database viewer"
/>
) : (
<p className="text-sm text-slate-500">Not configured for this deployment.</p>
)}
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
</div>
</div>
);
}
function DeleteAccountBox() {
const { data: systemStatus } = useSystemStatus();
const [password, setPassword] = useState("");
const deleteAccount = useDeleteAccount();
const isLocal = systemStatus ? isLocalConvenience(systemStatus) : false;
// The local-dev bypass account has no password_hash at all (it never
// registers/logs in) - there's nothing to type to confirm deletion, and
// deleting it would just get silently re-created on the next request.
if (isLocal) return null;
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-red-700">
<Trash2 className="h-4 w-4" aria-hidden /> Delete account
</div>
<p className="mt-1 text-sm text-red-700/80">
Permanently deletes your account and everything in it - companies, monitoring history,
reports, and API keys. This can&apos;t be undone.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
deleteAccount.mutate({ password });
}}
className="mt-4 flex flex-wrap items-end gap-2"
>
<div className="min-w-[220px] flex-1">
<label htmlFor="delete-account-password" className="block text-sm font-medium text-red-700">
Confirm your password
</label>
<input
id="delete-account-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="focus-ring mt-1 block w-full rounded-md border border-red-300 px-3 py-2 text-sm text-slate-900 shadow-sm transition-colors duration-150"
/>
</div>
<button
type="submit"
disabled={!password || deleteAccount.isPending}
className="focus-ring inline-flex shrink-0 items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{deleteAccount.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{deleteAccount.isPending ? "Deleting…" : "Delete account"}
</button>
</form>
{deleteAccount.isError && (
<p className="mt-2 text-sm text-red-700" role="alert">
{authErrorMessage(deleteAccount.error)}
</p>
)}
</div>
);
}
export default function SettingsPage() { export default function SettingsPage() {
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
const { data: systemStatus } = useSystemStatus(); const { data: systemStatus } = useSystemStatus();
@@ -759,9 +876,12 @@ export default function SettingsPage() {
<> <>
<ServerSecretsBox /> <ServerSecretsBox />
<IpBansBox /> <IpBansBox />
<DatabaseViewerBox />
<LoggingBox /> <LoggingBox />
</> </>
)} )}
<DeleteAccountBox />
</div> </div>
); );
} }
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { KeyRound, Loader2 } from "lucide-react";
import { z } from "zod";
import { PasswordField } from "@/components/ui/password-field";
import { PasswordStrengthMeter } from "@/components/ui/password-strength-meter";
import { authErrorMessage, useChangePassword } from "@/hooks/use-auth";
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "Enter your current password"),
newPassword: z
.string()
.min(10, "Must be at least 10 characters")
.refine((v) => /[a-zA-Z]/.test(v) && /\d/.test(v), {
message: "Must contain at least one letter and one digit",
}),
});
type ChangePasswordForm = z.infer<typeof changePasswordSchema>;
export default function ChangePasswordPage() {
const router = useRouter();
const changePassword = useChangePassword();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<ChangePasswordForm>({ resolver: zodResolver(changePasswordSchema) });
const onSubmit = handleSubmit(async (values) => {
try {
await changePassword.mutateAsync({
current_password: values.currentPassword,
new_password: values.newPassword,
});
router.replace("/dashboard");
} catch {
// Surfaced via changePassword.isError below.
}
});
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<div className="w-full max-w-sm animate-fade-in overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="h-1.5 bg-gradient-to-r from-brand-500 via-brand-600 to-brand-700" />
<div className="p-8">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<KeyRound className="h-4.5 w-4.5" aria-hidden />
</span>
<div>
<h1 className="text-xl font-semibold text-slate-900">Change your password</h1>
<p className="text-sm text-slate-600">
An admin requires a new password before you can continue.
</p>
</div>
</div>
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<PasswordField
label="Current password"
autoComplete="current-password"
{...register("currentPassword")}
error={errors.currentPassword?.message}
/>
<div>
<PasswordField
label="New password"
autoComplete="new-password"
hint="At least 10 characters, with a letter and a digit."
{...register("newPassword")}
error={errors.newPassword?.message}
/>
<PasswordStrengthMeter password={watch("newPassword") ?? ""} />
</div>
{changePassword.isError && (
<p className="animate-fade-in text-sm text-red-600" role="alert">
{authErrorMessage(changePassword.error)}
</p>
)}
<button
type="submit"
disabled={changePassword.isPending}
className="focus-ring inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{changePassword.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{changePassword.isPending ? "Updating…" : "Update password"}
</button>
</form>
</div>
</div>
</main>
);
}
+35
View File
@@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, clearTokens, getRefreshToken, setTokens } from "@/lib/api-client"; import { ApiError, api, clearTokens, getRefreshToken, setTokens } from "@/lib/api-client";
import type { import type {
BanIpPayload, BanIpPayload,
ChangePasswordPayload,
ConfirmPasswordResetPayload, ConfirmPasswordResetPayload,
DeleteAccountPayload,
LoginPayload, LoginPayload,
RegisterPayload, RegisterPayload,
RequestPasswordResetPayload, RequestPasswordResetPayload,
@@ -47,6 +49,12 @@ export function useSetSystemSecret() {
}); });
} }
export function useCreateDbViewerSession() {
return useMutation({
mutationFn: () => api.createDbViewerSession(),
});
}
export function useSystemLogs() { export function useSystemLogs() {
return useQuery({ return useQuery({
queryKey: ["system-logs"], queryKey: ["system-logs"],
@@ -104,6 +112,33 @@ export function useLogout() {
}); });
} }
export function useDeleteAccount() {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: DeleteAccountPayload) => api.deleteAccount(payload),
onSuccess: () => {
// Same reasoning as useLogout: we know for certain the account is
// gone, so clear and navigate immediately rather than waiting on a
// background refetch of ["me"] to fail.
clearTokens();
queryClient.clear();
router.replace("/");
},
});
}
export function useChangePassword() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ChangePasswordPayload) => api.changePassword(payload),
onSuccess: (tokens) => {
setTokens(tokens.access_token, tokens.refresh_token);
return queryClient.invalidateQueries({ queryKey: ["me"] });
},
});
}
export function useVerifyEmail() { export function useVerifyEmail() {
return useMutation({ return useMutation({
mutationFn: (payload: VerifyEmailPayload) => api.verifyEmail(payload), mutationFn: (payload: VerifyEmailPayload) => api.verifyEmail(payload),
+18
View File
@@ -10,7 +10,10 @@ import type {
CompanyResponse, CompanyResponse,
CompanyUpdatePayload, CompanyUpdatePayload,
ConfirmPasswordResetPayload, ConfirmPasswordResetPayload,
ChangePasswordPayload,
DashboardAnalytics, DashboardAnalytics,
DbViewerSessionResponse,
DeleteAccountPayload,
DiscoverCompanyRequest, DiscoverCompanyRequest,
DiscoveredCompanyProfile, DiscoveredCompanyProfile,
IpBan, IpBan,
@@ -166,6 +169,9 @@ export const api = {
listSystemSecrets: () => request<SystemSecretStatus[]>("/api/v1/system/secrets"), listSystemSecrets: () => request<SystemSecretStatus[]>("/api/v1/system/secrets"),
createDbViewerSession: () =>
request<DbViewerSessionResponse>("/api/v1/db-viewer/session", { method: "POST" }),
setSystemSecret: (key: string, payload: SetSystemSecretPayload) => setSystemSecret: (key: string, payload: SetSystemSecretPayload) =>
request<SystemSecretStatus>(`/api/v1/system/secrets/${key}`, { request<SystemSecretStatus>(`/api/v1/system/secrets/${key}`, {
method: "PUT", method: "PUT",
@@ -201,6 +207,18 @@ export const api = {
me: () => request<MeResponse>("/api/v1/auth/me"), me: () => request<MeResponse>("/api/v1/auth/me"),
deleteAccount: (payload: DeleteAccountPayload) =>
request<void>("/api/v1/auth/me", {
method: "DELETE",
body: JSON.stringify(payload),
}),
changePassword: (payload: ChangePasswordPayload) =>
request<TokenResponse>("/api/v1/auth/change-password", {
method: "POST",
body: JSON.stringify(payload),
}),
verifyEmail: (payload: VerifyEmailPayload) => verifyEmail: (payload: VerifyEmailPayload) =>
request<void>("/api/v1/auth/verify-email", { request<void>("/api/v1/auth/verify-email", {
method: "POST", method: "POST",
+14
View File
@@ -29,6 +29,19 @@ export interface SystemSecretStatus {
value: string | null; value: string | null;
} }
export interface DbViewerSessionResponse {
token: string;
}
export interface DeleteAccountPayload {
password: string;
}
export interface ChangePasswordPayload {
current_password: string;
new_password: string;
}
export interface SetSystemSecretPayload { export interface SetSystemSecretPayload {
value: string; value: string;
} }
@@ -66,6 +79,7 @@ export interface UserResponse {
timezone: string; timezone: string;
is_active: boolean; is_active: boolean;
is_admin: boolean; is_admin: boolean;
must_change_password: boolean;
} }
export interface MeResponse extends UserResponse { export interface MeResponse extends UserResponse {
+86
View File
@@ -0,0 +1,86 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import DashboardLayout from "@/app/(app)/layout";
import { renderWithQueryClient } from "./test-utils";
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
usePathname: () => "/dashboard",
}));
function meBody(mustChangePassword: boolean) {
return {
id: "user-1",
email: "[email protected]",
display_name: "Regular User",
timezone: "America/New_York",
is_active: true,
is_admin: false,
auth_mode: "jwt",
must_change_password: mustChangePassword,
};
}
function systemStatusBody() {
return {
app_env: "development",
auth_mode: "jwt",
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: false,
turnstile_site_key: null,
components: [],
};
}
function mockFetchImplementation(mustChangePassword: boolean) {
return vi.fn().mockImplementation((url: string) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/auth/me") {
return Promise.resolve({ ok: true, status: 200, json: async () => meBody(mustChangePassword) });
}
if (path === "/api/v1/system/status") {
return Promise.resolve({ ok: true, status: 200, json: async () => systemStatusBody() });
}
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
}
beforeEach(() => {
replaceMock.mockClear();
});
describe("DashboardLayout - must_change_password redirect", () => {
it("redirects to /change-password and withholds the page content when the flag is set", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(true));
renderWithQueryClient(
<DashboardLayout>
<div>Protected dashboard content</div>
</DashboardLayout>,
);
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/change-password");
});
expect(screen.queryByText(/protected dashboard content/i)).not.toBeInTheDocument();
});
it("renders normally when the flag is not set", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(false));
renderWithQueryClient(
<DashboardLayout>
<div>Protected dashboard content</div>
</DashboardLayout>,
);
expect(await screen.findByText(/protected dashboard content/i)).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalledWith("/change-password");
});
});
@@ -0,0 +1,86 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ChangePasswordPage from "@/app/change-password/page";
import { renderWithQueryClient } from "./test-utils";
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
}));
beforeEach(() => {
replaceMock.mockClear();
});
describe("ChangePasswordPage", () => {
it("rejects a weak new password before submitting", async () => {
const user = userEvent.setup();
renderWithQueryClient(<ChangePasswordPage />);
await user.type(screen.getByLabelText(/current password/i), "correct-horse-1");
await user.type(screen.getByLabelText(/new password/i), "allletters");
await user.click(screen.getByRole("button", { name: /update password/i }));
await waitFor(() => {
expect(
screen.getByText(/must contain at least one letter and one digit/i),
).toBeInTheDocument();
});
});
it("submits current + new password and redirects to the dashboard on success", async () => {
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/auth/change-password" && init?.method === "POST") {
expect(JSON.parse(init.body as string)).toEqual({
current_password: "correct-horse-1",
new_password: "brand-new-horse-2",
});
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: "new-access",
refresh_token: "new-refresh",
token_type: "bearer",
expires_in_minutes: 15,
}),
});
}
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<ChangePasswordPage />);
await user.type(screen.getByLabelText(/current password/i), "correct-horse-1");
await user.type(screen.getByLabelText(/new password/i), "brand-new-horse-2");
await user.click(screen.getByRole("button", { name: /update password/i }));
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
});
});
it("surfaces an error for the wrong current password", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ detail: "Incorrect current password" }),
}),
);
const user = userEvent.setup();
renderWithQueryClient(<ChangePasswordPage />);
await user.type(screen.getByLabelText(/current password/i), "wrong-password-1");
await user.type(screen.getByLabelText(/new password/i), "brand-new-horse-2");
await user.click(screen.getByRole("button", { name: /update password/i }));
expect(await screen.findByText(/incorrect current password/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,105 @@
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";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
const ADMIN_USER = {
id: "user-1",
email: "[email protected]",
display_name: "Admin",
timezone: "America/New_York",
is_active: true,
is_admin: true,
auth_mode: "jwt",
must_change_password: false,
};
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(<SettingsPage />);
const link = await screen.findByRole("link", { name: /open database viewer/i });
expect(link).toHaveAttribute("href", "http://localhost:8081/?pgsql=postgres&username=ciagent");
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(<SettingsPage />);
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(<SettingsPage />);
expect(await screen.findByText(/not configured for this deployment/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /open database viewer/i })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,148 @@
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 replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
}));
const REGULAR_USER = {
id: "user-1",
email: "[email protected]",
display_name: "Regular User",
timezone: "America/New_York",
is_active: true,
is_admin: false,
auth_mode: "jwt",
must_change_password: false,
};
const LOCAL_DEV_USER = {
id: "00000000-0000-0000-0000-000000000001",
email: "[email protected]",
display_name: "Local Developer",
timezone: "America/New_York",
is_active: true,
is_admin: true,
auth_mode: "local",
must_change_password: false,
};
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: [],
};
}
function mockFetchImplementation(
isLocalhost: boolean,
authMode: "local" | "jwt",
meUser: typeof REGULAR_USER,
) {
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(meUser);
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/auth/me" && init?.method === "DELETE") return ok(undefined, 204);
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
}
beforeEach(() => {
replaceMock.mockClear();
});
describe("Settings - Delete account box", () => {
it("renders for a regular (non-local-dev) user, disabled until a password is typed", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(false, "jwt", REGULAR_USER));
renderWithQueryClient(<SettingsPage />);
const button = await screen.findByRole("button", { name: /delete account/i });
expect(button).toBeDisabled();
const user = userEvent.setup();
await user.type(screen.getByLabelText(/confirm your password/i), "correct-horse-1");
expect(button).not.toBeDisabled();
});
it("is hidden entirely for the local-dev bypass account", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(true, "local", LOCAL_DEV_USER));
renderWithQueryClient(<SettingsPage />);
await screen.findByText(LOCAL_DEV_USER.email);
expect(screen.queryByRole("button", { name: /delete account/i })).not.toBeInTheDocument();
});
it("submits the password, then clears tokens and redirects home on success", async () => {
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/auth/me" && init?.method === "DELETE") {
expect(JSON.parse(init.body as string)).toEqual({ password: "correct-horse-1" });
return Promise.resolve({ ok: true, status: 204, json: async () => undefined });
}
return mockFetchImplementation(false, "jwt", REGULAR_USER)(url, init);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<SettingsPage />);
const button = await screen.findByRole("button", { name: /delete account/i });
await user.type(screen.getByLabelText(/confirm your password/i), "correct-horse-1");
await user.click(button);
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/");
});
});
it("shows an error message when the password is wrong", async () => {
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/auth/me" && init?.method === "DELETE") {
return Promise.resolve({
ok: false,
status: 401,
json: async () => ({ detail: "Incorrect password" }),
});
}
return mockFetchImplementation(false, "jwt", REGULAR_USER)(url, init);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<SettingsPage />);
const button = await screen.findByRole("button", { name: /delete account/i });
await user.type(screen.getByLabelText(/confirm your password/i), "wrong-password");
await user.click(button);
expect(await screen.findByText(/incorrect password/i)).toBeInTheDocument();
});
});
+15
View File
@@ -106,6 +106,7 @@ services:
args: args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} 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:-}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- api - api
@@ -150,6 +151,19 @@ services:
- "2222:22" - "2222:22"
logging: *default-logging 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: nginx:
image: nginx:1.27-alpine image: nginx:1.27-alpine
restart: unless-stopped restart: unless-stopped
@@ -157,6 +171,7 @@ services:
- web - web
- api - api
- gitea - gitea
- adminer
volumes: volumes:
- ./infrastructure/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./infrastructure/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Cloudflare Origin CA cert/key, generated once via the Cloudflare # Cloudflare Origin CA cert/key, generated once via the Cloudflare
+13
View File
@@ -110,5 +110,18 @@ services:
depends_on: depends_on:
- api - 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: volumes:
postgres-data: postgres-data:
+3 -1
View File
@@ -17,8 +17,10 @@ COPY apps/web ./
# *browser* will use, not a Docker-internal service name. See .env.example. # *browser* will use, not a Docker-internal service name. See .env.example.
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_GIT_REPO_URL ARG NEXT_PUBLIC_GIT_REPO_URL
ARG NEXT_PUBLIC_DB_VIEWER_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_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 RUN npm run build
+46 -1
View File
@@ -28,7 +28,7 @@ http {
# HTTPS at the edge, but the origin shouldn't 400 a direct :80 probe. # HTTPS at the edge, but the origin shouldn't 400 a direct :80 probe.
server { server {
listen 80; 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; return 301 https://$host$request_uri;
} }
@@ -79,4 +79,49 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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;
}
}
} }
+10
View File
@@ -28,4 +28,14 @@ docker compose -f docker-compose.prod.yml build api worker beat web
docker compose -f docker-compose.prod.yml run --rm api alembic upgrade head docker compose -f docker-compose.prod.yml run --rm api alembic upgrade head
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
# nginx's proxy_pass resolves the api/web service names to a container IP
# once, at its own worker-process startup - `up -d` above only recreates
# the containers whose image/config actually changed, so nginx (unchanged)
# keeps running with the OLD ip, now pointing at a dead container.
# Confirmed live: this caused a real multi-hour outage (every request to
# api.ciagent.org / ciagent.org 502'd) after the containers it proxies to
# got rebuilt out from under it. `restart` forces new worker processes,
# which re-resolve the current IPs via Docker's embedded DNS.
docker compose -f docker-compose.prod.yml restart nginx
echo "$(date -Iseconds) deploy complete: $REMOTE" echo "$(date -Iseconds) deploy complete: $REMOTE"