Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ BACKEND_PORT=8000
# Generate a strong random value in your real .env (e.g. `openssl rand -hex 32`):
SECRET_KEY=change-me-generate-a-random-secret

# ---- Authentication ----------------------------------------------------------
# How long a session token from POST /auth/login stays valid. Short enough that a leaked
# token expires on its own, long enough not to interrupt a working day.
AUTH_TOKEN_TTL_HOURS=12

# ---- Frontend ----------------------------------------------------------------
FRONTEND_PORT=3000
# The UI fetches findings in a Server Component, so this URL is resolved from inside the
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,51 @@ the console, and `make version-set` moves them together (issue #31).
updating `docs/STATUS.md` and adding a `[Unreleased]` changelog entry. Mirrored in
`.github/PULL_REQUEST_TEMPLATE.md`.

### Fixed
- **Validation errors no longer echo the submitted password or credential value.** FastAPI's
default handler returned Pydantic's error list verbatim, and a Pydantic `missing` error
carries the whole request object in `input`. `POST /auth/login` with the username omitted
returned the submitted password; `POST /engagements/{id}/credential` with `cred_type`
omitted returned the submitted scanning credential; `api_spec_inline` echoed the same way.
The credential case had been live for as long as the endpoint has existed (rule PX-SECRETS).
What comes back is the caller's own input, not stored state: validation runs before the
handler, so nothing is read from the database. It still matters, because a secret in a
response body ends up in proxy access logs, error trackers, and CI output (CWE-209).
A single `RequestValidationError` handler now serves every endpoint, keeping `type`/`loc`/
`msg` and dropping `input`/`ctx`/`url`; the response shape and the CLI's per-field error
rendering are unchanged. See KI-010 in `docs/KNOWN_ISSUES.md`.

### Added
- Authentication **foundation**: operator identity, roles, and sessions, proven on a small
set of endpoints rather than rolled out across the API. New `auth_user`, `auth_token`,
`auth_bootstrap`, and `auth_event` tables with a reversible migration (rule W-03).
Passwords are hashed with argon2id (`argon2-cffi`, MIT, at OWASP's 64 MiB / t=3 / p=4
baseline) and are never recoverable — deliberately not built on the reversible
evidence-crypto layer, which exists for a different job. Session tokens are 256 random
bits stored as SHA-256 only, so a stolen database row cannot be replayed as a bearer
token. `POST /auth/login` issues one token delivered two ways, in the response body and
as an httpOnly / SameSite / Secure cookie (rule A-03); `POST /auth/logout` revokes the
presenting session; `GET /auth/me` reports identity and role and never the hash.
`POST /auth/bootstrap` creates the first admin on a deployment that has none and then
refuses permanently — guarded by a fixed-primary-key marker row, so two concurrent
first-run requests cannot both win and deleting the admin does not re-open the door.
Every authentication action, including a failed login, writes one append-only
`auth_event` row (rules PX-SECRETS, PX-EVIDENCE, D-05).
- Roles `admin` / `operator` / `viewer` as an enum, with the whole authorization policy
encoded as a capability table in `app/api/deps.py` rather than as checks scattered
through handlers, plus `require_role` and `require_capability` dependencies (rule
B-FA-03). **Enforced on `GET /auth/me`, `POST /auth/logout`, and the admin-only
`POST /users` and `GET /users` — and on nothing else.** Every engagement and scan route
is deliberately unchanged and remains unauthenticated; applying the gate across them is
the next change, and `docs/STATUS.md` lists exactly which routes are which.
- `provx login`, `provx logout`, and `provx admin create`, with an on-disk token store at
`$XDG_CONFIG_HOME/provx/credentials.json` created owner-readable only. No password flag
exists on any command — the secret comes from a hidden prompt, stdin, or
`--password-env VAR` (rule PX-SECRETS) — and the parser-introspection test that enforced
that for `credential set` now walks the entire command tree. `$PROVX_TOKEN` still works
and takes precedence over a stored session.
- `AUTH_TOKEN_TTL_HOURS` (default 12), and a first-run walkthrough in
`docs/QUICKSTART.md` §4.
- One version for the whole monorepo, declared once per package and enforced by CI. Each
Python package takes its version from its own `__init__.py` via `dynamic = ["version"]`,
the new required `version-consistency` gate refuses any disagreement between declaration
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ The machine proposes findings; a human always confirms before anything is report
> reachable from the `provx` CLI, the web console, and the HTTP API. A lab of vulnerable and
> clean targets gates accuracy on TP/FP/FN in CI.
>
> **Not built:** authentication on the API itself (do not expose it), the job queue, the
> **Partly built — authentication.** Operator accounts, argon2id passwords, admin/operator/
> viewer roles, hashed session tokens, and `provx login` all exist. They are **enforced on
> `/auth/me`, `/auth/logout`, and `/users`, and on nothing else** — every engagement and scan
> route is still open. Applying the gate across them is the next change, so **do not expose
> this server**. [`docs/STATUS.md`](docs/STATUS.md) lists exactly which routes are which.
>
> **Not built:** the job queue, the
> playbook execution engine, exploitation, Word/`.docx` export, dashboard depth beyond the
> engagement list, and any AI feature. See [`docs/STATUS.md`](docs/STATUS.md) for the
> plan-of-record, [`docs/ROADMAP.md`](docs/ROADMAP.md) §4 for MVP scope, and
Expand Down Expand Up @@ -95,8 +101,10 @@ docker compose up --build
```

The API is at `http://localhost:8000` (interactive docs at `/docs`) and the web console at
`http://localhost:3000`. Migrations are applied on start. Note that **the API has no
authentication yet** — keep it on loopback and off shared networks.
`http://localhost:3000`. Migrations are applied on start. Note that **the engagement and
scan routes are still unauthenticated** — accounts and the role gate exist, but are enforced
only on `/auth/me`, `/auth/logout`, and `/users` so far. Keep the server on loopback and off
shared networks. Run `provx admin create --username root` on first start.

**→ [`docs/QUICKSTART.md`](docs/QUICKSTART.md) walks the full first scan end to end** — a
deliberately-vulnerable practice target you are unambiguously allowed to scan, then
Expand Down
10 changes: 6 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ responsibly.

## Supported versions

Provx is pre-1.0 and under active development. Until the first tagged release, only
the `main` branch is supported. Once releases exist, this table will list the versions
that receive security fixes.
Provx is pre-1.0 and under active development. Security fixes land on `main` and reach
users in the next tagged release; there are no backports to older tags, because pre-1.0
the upgrade path is forward.

| Version | Supported |
|---|---|
| `main` (unreleased) | ✅ |
| `main` | ✅ |
| `v0.1.1` (latest release) | ✅ |
| `v0.1.0` and earlier | ❌ upgrade to the latest tag |

## Reporting a vulnerability

Expand Down
107 changes: 107 additions & 0 deletions backend/alembic/versions/c3d4e5f6a7b8_add_auth_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Solomon Nii Amu Darku
"""authentication foundation: users, tokens, bootstrap marker, audit trail

Adds ``auth_user`` (operator identity + argon2id password hash + role), ``auth_token`` (live
sessions, stored as SHA-256 of the issued secret so a stolen row cannot be replayed),
``auth_bootstrap`` (the one-row marker that closes first-run admin creation permanently), and
``auth_event`` (the append-only trail of every authentication and user-management action -
rules PX-SECRETS, PX-EVIDENCE).

No password, hash, or token value is stored in a recoverable form anywhere in this schema.

Reversible (rule W-03): ``downgrade`` drops all four tables and the role enum type.

Revision ID: c3d4e5f6a7b8
Revises: a2b3c4d5e6f7
Create Date: 2026-07-30

"""

from collections.abc import Sequence

import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql

from alembic import op

revision: str = "c3d4e5f6a7b8"
down_revision: str | None = "a2b3c4d5e6f7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

# Created with the table that uses it, mirroring how approvalstatus and findingstatus are
# handled. On SQLite this renders as VARCHAR; on PostgreSQL it is a real enum type, so the
# database itself refuses a role the application does not define.
ROLE = postgresql.ENUM("ADMIN", "OPERATOR", "VIEWER", name="role")


def upgrade() -> None:
"""Create the identity, session, bootstrap-marker, and audit tables."""
op.create_table(
"auth_user",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("password_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("role", ROLE, nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_auth_user_username"), "auth_user", ["username"], unique=True)
op.create_index(op.f("ix_auth_user_email"), "auth_user", ["email"], unique=True)

op.create_table(
"auth_token",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("token_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["auth_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_auth_token_token_hash"), "auth_token", ["token_hash"], unique=True)
op.create_index(op.f("ix_auth_token_user_id"), "auth_token", ["user_id"], unique=False)

op.create_table(
"auth_bootstrap",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["auth_user.id"]),
sa.PrimaryKeyConstraint("id"),
)

op.create_table(
"auth_event",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("event_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("outcome", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("actor_user_id", sa.Uuid(), nullable=True),
sa.Column("username_attempted", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("detail", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["auth_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_auth_event_actor_user_id"), "auth_event", ["actor_user_id"], unique=False
)


def downgrade() -> None:
"""Drop the four tables in dependency order, then the role enum type."""
op.drop_index(op.f("ix_auth_event_actor_user_id"), table_name="auth_event")
op.drop_table("auth_event")
op.drop_table("auth_bootstrap")
op.drop_index(op.f("ix_auth_token_user_id"), table_name="auth_token")
op.drop_index(op.f("ix_auth_token_token_hash"), table_name="auth_token")
op.drop_table("auth_token")
op.drop_index(op.f("ix_auth_user_email"), table_name="auth_user")
op.drop_index(op.f("ix_auth_user_username"), table_name="auth_user")
op.drop_table("auth_user")
ROLE.drop(op.get_bind(), checkfirst=True)
149 changes: 149 additions & 0 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Solomon Nii Amu Darku
"""
Authentication routes: first-run bootstrap, login, logout, who-am-I.

``/auth/login`` and ``/auth/bootstrap`` are open by necessity - a caller with no credential
has to be able to obtain one. ``/auth/logout`` and ``/auth/me`` require a valid token.

Every response here is built from a whitelisting schema, and ``UserRead`` has no field a
password hash could occupy (rules B-FA-07, PX-SECRETS). Failures answer with a stable error
code and a generic message; a wrong password and an unknown username are indistinguishable, so
this endpoint cannot be used to enumerate accounts (rules PX-ERRORS, S-13).
"""

from __future__ import annotations

import logging
import uuid

from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel.ext.asyncio.session import AsyncSession

from app.api.deps import SESSION_COOKIE_NAME, current_token_id, current_user
from app.api.schemas import BootstrapRequest, LoginRead, LoginRequest, UserRead
from app.config import get_settings
from app.db import get_session
from app.models.tables import User
from app.security.passwords import PasswordPolicyError
from app.services.users import (
BootstrapClosedError,
UsernameTakenError,
authenticate,
bootstrap_admin,
issue_token,
revoke_token,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/auth", tags=["auth"])

INVALID_CREDENTIALS = "invalid_credentials"
BOOTSTRAP_CLOSED = "bootstrap_closed"
USERNAME_TAKEN = "username_taken"
WEAK_PASSWORD = "weak_password"


def user_to_read(row: User) -> UserRead:
"""Project a stored user onto its public shape, field by field.

Hand-written rather than constructed from the row's attributes, matching every other
projector in this API: a column added to ``auth_user`` stays unpublished until someone
writes the line that publishes it (rule B-FA-01).
"""
return UserRead(
id=row.id,
username=row.username,
email=row.email,
role=str(row.role),
is_active=row.is_active,
created_at=row.created_at,
)


@router.post("/bootstrap", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def bootstrap(
payload: BootstrapRequest, session: AsyncSession = Depends(get_session)
) -> UserRead:
"""Create the first admin. Works only while no bootstrap has ever completed.

This is not a backdoor: the marker row that records completion is permanent, so the route
refuses forever afterwards - including after the admin it created has been deleted.
"""
try:
admin = await bootstrap_admin(
session,
username=payload.username,
password=payload.password,
email=payload.email,
)
except BootstrapClosedError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error_code": BOOTSTRAP_CLOSED,
"message": "An administrator already exists. Ask them to create your account.",
},
) from exc
except PasswordPolicyError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error_code": WEAK_PASSWORD, "message": str(exc)},
) from exc
except UsernameTakenError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"error_code": USERNAME_TAKEN, "message": "That username is already in use."},
) from exc
logger.info("bootstrap admin created", extra={"username": admin.username})
return user_to_read(admin)


@router.post("/login", response_model=LoginRead, status_code=status.HTTP_200_OK)
async def login(
payload: LoginRequest, response: Response, session: AsyncSession = Depends(get_session)
) -> LoginRead:
"""Exchange a username and password for a session token.

The token is returned in the body for the CLI and API, and set as an httpOnly, SameSite
cookie for the console (rule A-03). Both are the same secret and one logout ends both.
"""
user = await authenticate(session, username=payload.username, password=payload.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error_code": INVALID_CREDENTIALS, "message": "Invalid username or password."},
)
issued = await issue_token(session, user)
settings = get_settings()
response.set_cookie(
SESSION_COOKIE_NAME,
issued.raw,
httponly=True,
# Off only in a development environment, where there is no TLS to require. Production
# fails to the safe setting because is_debug_env fails closed (rule PX-ERRORS).
secure=not settings.is_debug_env,
samesite="lax",
max_age=settings.auth_token_ttl_hours * 3600,
path="/",
)
return LoginRead(token=issued.raw, expires_at=issued.expires_at, user=user_to_read(user))


@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
response: Response,
user: User = Depends(current_user),
token_id: uuid.UUID = Depends(current_token_id),
session: AsyncSession = Depends(get_session),
) -> None:
"""Revoke the presenting token and clear the session cookie."""
await revoke_token(session, token_id, user)
response.delete_cookie(SESSION_COOKIE_NAME, path="/")


@router.get("/me", response_model=UserRead, status_code=status.HTTP_200_OK)
async def me(user: User = Depends(current_user)) -> UserRead:
"""Return the authenticated operator's identity and role - never the password hash."""
return user_to_read(user)
Loading
Loading