Compare commits

..
1 Commits
Author SHA1 Message Date
Ian Roddis f2b99089ea Adding LDAP support 2026-07-19 12:02:41 -03:00
44 changed files with 550 additions and 930 deletions

No files matched your search

+27 -1
View File
@@ -2,7 +2,7 @@
# environment variable (which takes precedence over the .env file). # environment variable (which takes precedence over the .env file).
# --- General --- # --- General ---
APP_NAME="Armament FitGuide" APP_NAME="FastAPI Skeleton"
DEBUG=false DEBUG=false
# --- Security --- # --- Security ---
@@ -29,6 +29,32 @@ COOKIE_SECURE=false
# --- CORS (JSON list) --- # --- CORS (JSON list) ---
CORS_ORIGINS=["*"] CORS_ORIGINS=["*"]
# --- LDAP / Active Directory (optional) ---
# When enabled, login credentials are validated against the directory and a
# local user is provisioned on first login. Local DB auth still works as a
# fallback (so the seeded superuser can log in). Disabled by default.
LDAP_ENABLED=false
LDAP_SERVER_URI="ldap://ldap.example.com:389"
LDAP_USE_SSL=false
LDAP_START_TLS=false
LDAP_TLS_VALIDATE=true
# LDAP_CA_CERTS_FILE="/etc/ssl/certs/ca.pem"
# Option 1 — direct bind: build the user DN from the login name.
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
# Option 2 — search + bind: a service account finds the user, then we bind as
# the found DN. Leave the template above empty to use this mode.
# LDAP_BIND_DN="cn=svc-reader,dc=example,dc=com"
# LDAP_BIND_PASSWORD="service-account-password"
# LDAP_USER_SEARCH_BASE="ou=people,dc=example,dc=com"
# LDAP_USER_SEARCH_FILTER="(uid={username})"
# Directory attributes copied onto the local user, and roles granted on first login.
LDAP_ATTR_EMAIL=mail
LDAP_ATTR_FULL_NAME=cn
LDAP_DEFAULT_ROLES=["user"]
# --- Bootstrap superuser (used by scripts/seed.py) --- # --- Bootstrap superuser (used by scripts/seed.py) ---
FIRST_SUPERUSER_EMAIL=admin@example.com FIRST_SUPERUSER_EMAIL=admin@example.com
FIRST_SUPERUSER_PASSWORD=changethis FIRST_SUPERUSER_PASSWORD=changethis
+7
View File
@@ -44,6 +44,13 @@ token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
- **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an - **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`. failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`.
- **Credential verification** goes through `services/auth.authenticate(db, username,
password)` — both login routes call it, not `crud.user.authenticate` directly.
When `settings.ldap_enabled`, it binds against LDAP first (`services/ldap.py`,
sync `ldap3` offloaded via `run_in_threadpool`), provisions/refreshes the local
row via `crud.user.upsert_ldap_user` (unusable local password, `auth_provider="ldap"`,
`LDAP_DEFAULT_ROLES`), then falls back to local DB auth. LDAP config lives in
`settings.ldap_*`. Tests fake the blocking bind by patching `services.ldap._authenticate_sync`.
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the - **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues. `refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
-23
View File
@@ -1,23 +0,0 @@
- Parts
- Type
- Scope
- Adapter Ring
- ARD
- Polarizer
- Flip Cover
- Attachment Point +
- Name
- Attachment
- One Of
- ISO Thread
- UNC Thread
- Outside Diameter
- static value (to be attached to)
- Clamp
- min
- max
- Manufacturers
- Models
- Colours
- Part Types
+24 -1
View File
@@ -5,7 +5,7 @@ A production-shaped starting point for a web app that serves both **HTML (HTMX)*
## Stack ## Stack
| Concern | Choice | | Concern | Choice |
|----------------|--------| | -------------------- | ----------------------------------------------------------- |
| Web framework | FastAPI | | Web framework | FastAPI |
| HTML / interactivity | Jinja2 + HTMX | | HTML / interactivity | Jinja2 + HTMX |
| ORM | SQLAlchemy 2.0 (async) | | ORM | SQLAlchemy 2.0 (async) |
@@ -154,6 +154,7 @@ Every setting is read from an environment variable first, then `.env`
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod | | `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS | | `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed | | `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
| `LDAP_ENABLED` | `false` | validate logins against LDAP/AD (see below) |
## Auth model ## Auth model
@@ -168,6 +169,28 @@ stores the access token in an httpOnly cookie so pages work without client JS.
Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked` Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked`
flag, so logout and rotation genuinely invalidate them. flag, so logout and rotation genuinely invalidate them.
### LDAP / Active Directory (optional)
Set `LDAP_ENABLED=true` to validate logins against a directory. On a successful
bind, a local user is **provisioned on first login** (with an unusable local
password and the roles in `LDAP_DEFAULT_ROLES`) so RBAC, tokens, and the admin
UI work unchanged. Existing accounts still authenticate against the local
database as a fallback, so the seeded superuser keeps working.
Two lookup modes (configured in `.env`, see `.env.example`):
- **Direct bind** — set `LDAP_BIND_DN_TEMPLATE`, e.g.
`uid={username},ou=people,dc=example,dc=com`; the login name is substituted
and used to bind directly.
- **Search + bind** — leave the template empty and set `LDAP_BIND_DN` /
`LDAP_BIND_PASSWORD` (a service account) plus `LDAP_USER_SEARCH_BASE` /
`LDAP_USER_SEARCH_FILTER`; the user is located, then re-bound to verify the
password.
The local user's email/name come from the `LDAP_ATTR_EMAIL` / `LDAP_ATTR_FULL_NAME`
attributes. Use `ldaps://` (`LDAP_USE_SSL=true`) or `LDAP_START_TLS=true` in
production so credentials aren't sent in cleartext.
## RBAC ## RBAC
`users → roles → permissions`. Permissions follow `resource:action` `users → roles → permissions`. Permissions follow `resource:action`
@@ -0,0 +1,33 @@
"""add auth_provider to users
Revision ID: 0002_add_auth_provider
Revises: 0001_initial
Create Date: 2026-07-19 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002_add_auth_provider"
down_revision: str | None = "0001_initial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"auth_provider",
sa.String(length=20),
nullable=False,
server_default="local",
),
)
def downgrade() -> None:
op.drop_column("users", "auth_provider")
@@ -1,42 +0,0 @@
"""add iso_threads and unc_threads tables
Revision ID: 627d4460a94f
Revises: 0001_initial
Create Date: 2026-06-24 21:50:05.340235
"""
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "627d4460a94f"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"iso_threads",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("diameter_mm", sa.Float(), nullable=False),
sa.Column("pitch_mm", sa.Float(), nullable=False),
sa.Column("depth_mm", sa.Float(), nullable=True),
)
op.create_table(
"unc_threads",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("diameter_fraction", sa.String(length=16), nullable=False),
sa.Column("tpi", sa.Integer(), nullable=False),
sa.Column("series", sa.String(length=8), nullable=False),
sa.Column("depth_in", sa.Float(), nullable=True),
)
def downgrade() -> None:
op.drop_table("unc_threads")
op.drop_table("iso_threads")
+8 -3
View File
@@ -10,7 +10,12 @@ from app.deps import get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead from app.schemas.user import RegisterRequest, UserRead
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token from app.services.auth import (
RefreshError,
authenticate,
issue_token_pair,
rotate_refresh_token,
)
router = APIRouter() router = APIRouter()
@@ -40,8 +45,8 @@ async def login(
form: OAuth2PasswordRequestForm = Depends(), form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# OAuth2 form uses ``username``; we treat it as the email. # OAuth2 form uses ``username``; we treat it as the email (or LDAP login).
user = await user_crud.authenticate(db, form.username, form.password) user = await authenticate(db, form.username, form.password)
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
-71
View File
@@ -1,71 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import iso_thread as iso_thread_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.iso_thread import IsoThreadCreate, IsoThreadRead, IsoThreadUpdate
router = APIRouter()
@router.get("", response_model=list[IsoThreadRead])
async def list_iso_threads(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:read")),
):
return await iso_thread_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=IsoThreadRead, status_code=status.HTTP_201_CREATED)
async def create_iso_thread(
payload: IsoThreadCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:write")),
):
return await iso_thread_crud.create(
db,
diameter_mm=payload.diameter_mm,
pitch_mm=payload.pitch_mm,
depth_mm=payload.depth_mm,
)
@router.get("/{iso_thread_id}", response_model=IsoThreadRead)
async def get_iso_thread(
iso_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:read")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
return thread
@router.patch("/{iso_thread_id}", response_model=IsoThreadRead)
async def update_iso_thread(
iso_thread_id: int,
payload: IsoThreadUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:write")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
return await iso_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True))
@router.delete("/{iso_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_iso_thread(
iso_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:delete")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
await iso_thread_crud.delete(db, thread)
+1 -3
View File
@@ -1,10 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api import auth, iso_threads, roles, unc_threads, users from app.api import auth, roles, users
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(iso_threads.router, prefix="/iso-threads", tags=["iso-threads"])
api_router.include_router(unc_threads.router, prefix="/unc-threads", tags=["unc-threads"])
-72
View File
@@ -1,72 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import unc_thread as unc_thread_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.unc_thread import UncThreadCreate, UncThreadRead, UncThreadUpdate
router = APIRouter()
@router.get("", response_model=list[UncThreadRead])
async def list_unc_threads(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:read")),
):
return await unc_thread_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=UncThreadRead, status_code=status.HTTP_201_CREATED)
async def create_unc_thread(
payload: UncThreadCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:write")),
):
return await unc_thread_crud.create(
db,
diameter_fraction=payload.diameter_fraction,
tpi=payload.tpi,
series=payload.series,
depth_in=payload.depth_in,
)
@router.get("/{unc_thread_id}", response_model=UncThreadRead)
async def get_unc_thread(
unc_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:read")),
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
return thread
@router.patch("/{unc_thread_id}", response_model=UncThreadRead)
async def update_unc_thread(
unc_thread_id: int,
payload: UncThreadUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:write")),
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
return await unc_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True))
@router.delete("/{unc_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_unc_thread(
unc_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:delete")),
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
await unc_thread_crud.delete(db, thread)
+34
View File
@@ -40,6 +40,40 @@ class Settings(BaseSettings):
# CORS # CORS
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
# --- LDAP / Active Directory (optional) --------------------------------
# When ``ldap_enabled`` is true, login credentials are validated by binding
# to the directory. A local ``User`` row is provisioned on first successful
# login so RBAC, tokens, and the admin UI keep working. Local database auth
# remains available as a fallback (so the seeded superuser can still log in).
ldap_enabled: bool = False
# e.g. "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636"
ldap_server_uri: str = ""
ldap_use_ssl: bool = False
ldap_start_tls: bool = False
# Verify the server's TLS certificate (ldaps/StartTLS). Disable only for dev.
ldap_tls_validate: bool = True
ldap_ca_certs_file: str = ""
# Two ways to locate/authenticate the user (direct bind takes precedence):
#
# 1. Direct bind — build the user's DN from the login name, then bind as it:
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
ldap_bind_dn_template: str = ""
#
# 2. Search + bind — bind with a service account (or anonymously), search for
# the user, then re-bind as the found DN to verify the password:
ldap_bind_dn: str = ""
ldap_bind_password: str = ""
ldap_user_search_base: str = ""
ldap_user_search_filter: str = "(uid={username})"
# Attributes read from the directory entry to populate the local user.
ldap_attr_email: str = "mail"
ldap_attr_full_name: str = "cn"
# Roles (by name) granted to a user provisioned from LDAP on first login.
ldap_default_roles: list[str] = ["user"]
# Bootstrap superuser (used by scripts/seed.py) # Bootstrap superuser (used by scripts/seed.py)
first_superuser_email: str = "admin@example.com" first_superuser_email: str = "admin@example.com"
first_superuser_password: str = "changethis" first_superuser_password: str = "changethis"
+11
View File
@@ -1,6 +1,7 @@
"""Password hashing and JWT token helpers.""" """Password hashing and JWT token helpers."""
import hashlib import hashlib
import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -27,6 +28,16 @@ def verify_password(password: str, hashed: str) -> bool:
return False return False
def make_unusable_password() -> str:
"""A sentinel hash that no password can match.
Used for externally-authenticated users (e.g. LDAP) who have no local
password. It is not a valid bcrypt hash, so ``verify_password`` always
returns ``False`` for it — local password auth can never succeed.
"""
return "!" + secrets.token_urlsafe(32)
# --- JWT --------------------------------------------------------------------- # --- JWT ---------------------------------------------------------------------
-42
View File
@@ -1,42 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.iso_thread import IsoThread
async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None:
return await db.get(IsoThread, iso_thread_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[IsoThread]:
result = await db.execute(
select(IsoThread).order_by(IsoThread.id).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
diameter_mm: float,
pitch_mm: float,
depth_mm: float | None = None,
) -> IsoThread:
thread = IsoThread(diameter_mm=diameter_mm, pitch_mm=pitch_mm, depth_mm=depth_mm)
db.add(thread)
await db.commit()
await db.refresh(thread)
return thread
async def update(db: AsyncSession, thread: IsoThread, data: dict) -> IsoThread:
for field, value in data.items():
setattr(thread, field, value)
await db.commit()
await db.refresh(thread)
return thread
async def delete(db: AsyncSession, thread: IsoThread) -> None:
await db.delete(thread)
await db.commit()
-48
View File
@@ -1,48 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.unc_thread import UncSeries, UncThread
async def get(db: AsyncSession, unc_thread_id: int) -> UncThread | None:
return await db.get(UncThread, unc_thread_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[UncThread]:
result = await db.execute(
select(UncThread).order_by(UncThread.id).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
diameter_fraction: str,
tpi: int,
series: UncSeries,
depth_in: float | None = None,
) -> UncThread:
thread = UncThread(
diameter_fraction=diameter_fraction,
tpi=tpi,
series=series,
depth_in=depth_in,
)
db.add(thread)
await db.commit()
await db.refresh(thread)
return thread
async def update(db: AsyncSession, thread: UncThread, data: dict) -> UncThread:
for field, value in data.items():
setattr(thread, field, value)
await db.commit()
await db.refresh(thread)
return thread
async def delete(db: AsyncSession, thread: UncThread) -> None:
await db.delete(thread)
await db.commit()
+51 -1
View File
@@ -1,7 +1,7 @@
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password from app.core.security import hash_password, make_unusable_password, verify_password
from app.models.role import Role from app.models.role import Role
from app.models.user import User from app.models.user import User
@@ -32,6 +32,13 @@ async def _resolve_roles(db: AsyncSession, role_ids: list[int]) -> list[Role]:
return list(result.scalars().all()) return list(result.scalars().all())
async def _resolve_roles_by_name(db: AsyncSession, names: list[str]) -> list[Role]:
if not names:
return []
result = await db.execute(select(Role).where(Role.name.in_(names)))
return list(result.scalars().all())
async def create( async def create(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -40,12 +47,14 @@ async def create(
full_name: str | None = None, full_name: str | None = None,
is_superuser: bool = False, is_superuser: bool = False,
role_ids: list[int] | None = None, role_ids: list[int] | None = None,
auth_provider: str = "local",
) -> User: ) -> User:
user = User( user = User(
email=email, email=email,
hashed_password=hash_password(password), hashed_password=hash_password(password),
full_name=full_name, full_name=full_name,
is_superuser=is_superuser, is_superuser=is_superuser,
auth_provider=auth_provider,
roles=await _resolve_roles(db, role_ids or []), roles=await _resolve_roles(db, role_ids or []),
) )
db.add(user) db.add(user)
@@ -54,6 +63,47 @@ async def create(
return user return user
async def upsert_ldap_user(
db: AsyncSession,
*,
email: str,
full_name: str | None = None,
default_role_names: list[str] | None = None,
) -> User:
"""Provision (or refresh) the local row for an LDAP-authenticated user.
On first login the user is created with an unusable local password (so
password auth can never succeed) and the configured default roles. On later
logins the existing row is reused; only ``full_name`` is synced from the
directory. Roles assigned afterwards in the admin UI are preserved.
"""
user = await get_by_email(db, email)
if user is None:
user = User(
email=email,
hashed_password=make_unusable_password(),
full_name=full_name,
auth_provider="ldap",
roles=await _resolve_roles_by_name(db, default_role_names or []),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
changed = False
if user.auth_provider != "ldap":
user.auth_provider = "ldap"
changed = True
if full_name and user.full_name != full_name:
user.full_name = full_name
changed = True
if changed:
await db.commit()
await db.refresh(user)
return user
async def update(db: AsyncSession, user: User, data: dict) -> User: async def update(db: AsyncSession, user: User, data: dict) -> User:
if "password" in data and data["password"]: if "password" in data and data["password"]:
user.hashed_password = hash_password(data.pop("password")) user.hashed_password = hash_password(data.pop("password"))
-4
View File
@@ -1,19 +1,15 @@
"""Import all models so they register with ``Base.metadata``.""" """Import all models so they register with ``Base.metadata``."""
from app.models.associations import role_permissions, user_roles from app.models.associations import role_permissions, user_roles
from app.models.iso_thread import IsoThread
from app.models.permission import Permission from app.models.permission import Permission
from app.models.refresh_token import RefreshToken from app.models.refresh_token import RefreshToken
from app.models.role import Role from app.models.role import Role
from app.models.unc_thread import UncThread
from app.models.user import User from app.models.user import User
__all__ = [ __all__ = [
"IsoThread",
"Permission", "Permission",
"RefreshToken", "RefreshToken",
"Role", "Role",
"UncThread",
"User", "User",
"role_permissions", "role_permissions",
"user_roles", "user_roles",
-13
View File
@@ -1,13 +0,0 @@
from sqlalchemy import Float
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class IsoThread(Base):
__tablename__ = "iso_threads"
id: Mapped[int] = mapped_column(primary_key=True)
diameter_mm: Mapped[float] = mapped_column(Float, nullable=False)
pitch_mm: Mapped[float] = mapped_column(Float, nullable=False)
depth_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
-24
View File
@@ -1,24 +0,0 @@
import enum
from sqlalchemy import Enum, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class UncSeries(str, enum.Enum):
UNC = "UNC"
UNF = "UNF"
UNEF = "UNEF"
UN = "UN"
UNS = "UNS"
class UncThread(Base):
__tablename__ = "unc_threads"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
diameter_fraction: Mapped[str] = mapped_column(String(16), nullable=False)
tpi: Mapped[int] = mapped_column(Integer, nullable=False)
series: Mapped[UncSeries] = mapped_column(Enum(UncSeries), nullable=False)
depth_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
+4
View File
@@ -16,6 +16,10 @@ class User(Base):
full_name: Mapped[str | None] = mapped_column(String(255), default=None) full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
# Where this account authenticates: "local" (password) or "ldap".
auth_provider: Mapped[str] = mapped_column(
String(20), default="local", server_default="local"
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )
-23
View File
@@ -1,23 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field
class IsoThreadBase(BaseModel):
diameter_mm: float = Field(..., gt=0, description="Nominal diameter in mm")
pitch_mm: float = Field(..., gt=0, description="Thread pitch in mm")
depth_mm: float | None = Field(None, gt=0, description="Thread depth in mm")
class IsoThreadCreate(IsoThreadBase):
pass
class IsoThreadUpdate(BaseModel):
diameter_mm: float | None = Field(None, gt=0)
pitch_mm: float | None = Field(None, gt=0)
depth_mm: float | None = Field(None, gt=0)
class IsoThreadRead(IsoThreadBase):
model_config = ConfigDict(from_attributes=True)
id: int
-52
View File
@@ -1,52 +0,0 @@
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.models.unc_thread import UncSeries
_FRACTION_RE = re.compile(r"^\d+(/\d+)?(-\d+/\d+)?$")
class UncThreadBase(BaseModel):
diameter_fraction: str = Field(
...,
description='Nominal diameter as a fractional inch string, e.g. "1/4", "3/8", "1-1/2"',
)
tpi: int = Field(..., gt=0, description="Threads per inch")
series: UncSeries
depth_in: float | None = Field(None, gt=0, description="Thread depth in inches")
@field_validator("diameter_fraction")
@classmethod
def validate_fraction(cls, v: str) -> str:
if not _FRACTION_RE.match(v):
raise ValueError(
'diameter_fraction must be a fractional inch string, e.g. "1/4", "3/8", "1-1/2"'
)
return v
class UncThreadCreate(UncThreadBase):
pass
class UncThreadUpdate(BaseModel):
diameter_fraction: str | None = None
tpi: int | None = Field(None, gt=0)
series: UncSeries | None = None
depth_in: float | None = Field(None, gt=0)
@field_validator("diameter_fraction")
@classmethod
def validate_fraction(cls, v: str | None) -> str | None:
if v is not None and not _FRACTION_RE.match(v):
raise ValueError(
'diameter_fraction must be a fractional inch string, e.g. "1/4", "3/8", "1-1/2"'
)
return v
class UncThreadRead(UncThreadBase):
model_config = ConfigDict(from_attributes=True)
id: int
+1
View File
@@ -31,6 +31,7 @@ class UserRead(UserBase):
id: int id: int
is_active: bool is_active: bool
is_superuser: bool is_superuser: bool
auth_provider: str = "local"
created_at: datetime created_at: datetime
roles: list[RoleRead] = [] roles: list[RoleRead] = []
+33 -1
View File
@@ -1,8 +1,12 @@
"""Token issuance / rotation shared by the JSON API and the HTMX UI.""" """Auth orchestration shared by the JSON API and the HTMX UI.
Covers credential verification (LDAP + local) and token issuance / rotation.
"""
import jwt import jwt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import ( from app.core.security import (
REFRESH_TOKEN_TYPE, REFRESH_TOKEN_TYPE,
create_access_token, create_access_token,
@@ -10,7 +14,35 @@ from app.core.security import (
decode_token, decode_token,
) )
from app.crud import token as token_crud from app.crud import token as token_crud
from app.crud import user as user_crud
from app.models.user import User from app.models.user import User
from app.services import ldap as ldap_service
async def authenticate(db: AsyncSession, username: str, password: str) -> User | None:
"""Verify credentials and return the matching active user, or ``None``.
When LDAP is enabled the directory is tried first; on a successful bind the
local user row is provisioned/refreshed. Local database authentication is
always attempted as a fallback, so pre-existing accounts (e.g. the seeded
superuser) keep working alongside LDAP.
"""
if settings.ldap_enabled:
ldap_user = await ldap_service.authenticate_ldap(username, password)
if ldap_user is not None:
user = await user_crud.upsert_ldap_user(
db,
email=ldap_user.email,
full_name=ldap_user.full_name,
default_role_names=settings.ldap_default_roles,
)
return user if user.is_active else None
# Local password auth (the only path when LDAP is disabled).
user = await user_crud.authenticate(db, username, password)
if user is not None and not user.is_active:
return None
return user
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]: async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
+158
View File
@@ -0,0 +1,158 @@
"""LDAP / Active Directory authentication.
Enabled and configured via ``settings.ldap_*`` (see ``app/core/config.py``).
When enabled, ``authenticate_ldap`` validates a login by binding to the
directory. On success it returns the directory attributes needed to provision
or update a local ``User`` row; the local row is what RBAC, tokens, and the
admin UI operate on.
``ldap3`` is synchronous, so the blocking bind/search runs in a worker thread
via ``run_in_threadpool`` to avoid stalling the async event loop. ``ldap3`` is
imported lazily so the rest of the app runs even when it is not installed and
LDAP is disabled.
"""
from dataclasses import dataclass
from starlette.concurrency import run_in_threadpool
from app.core.config import settings
@dataclass(frozen=True)
class LdapUser:
"""Identity resolved from the directory for a successfully-bound user."""
email: str
full_name: str | None = None
async def authenticate_ldap(username: str, password: str) -> LdapUser | None:
"""Return the directory identity if ``username``/``password`` bind, else None.
Never raises for an ordinary auth failure (bad credentials, user not found,
server unreachable) — those all return ``None``.
"""
if not settings.ldap_enabled:
return None
if not username or not password:
# An empty password would trigger an anonymous bind that "succeeds"
# without proving anything — treat it as a failed login.
return None
return await run_in_threadpool(_authenticate_sync, username, password)
def _build_server():
from ldap3 import Server, Tls
tls = None
if settings.ldap_use_ssl or settings.ldap_start_tls:
import ssl
validate = ssl.CERT_REQUIRED if settings.ldap_tls_validate else ssl.CERT_NONE
tls = Tls(
validate=validate,
ca_certs_file=settings.ldap_ca_certs_file or None,
)
return Server(
settings.ldap_server_uri,
use_ssl=settings.ldap_use_ssl,
tls=tls,
get_info="NONE",
)
def _authenticate_sync(username: str, password: str) -> LdapUser | None:
"""Blocking bind + attribute lookup. Runs in a worker thread."""
from ldap3 import Connection
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import escape_rdn
server = _build_server()
try:
if settings.ldap_bind_dn_template:
# Direct bind: construct the DN and bind straight as the user.
user_dn = settings.ldap_bind_dn_template.format(
username=escape_rdn(username)
)
return _bind_and_read(server, user_dn, password, username)
# Search + bind: locate the entry first, then bind as its DN.
search_filter = settings.ldap_user_search_filter.format(
username=escape_filter_chars(username)
)
finder = Connection(
server,
user=settings.ldap_bind_dn or None,
password=settings.ldap_bind_password or None,
auto_bind=True,
)
try:
attrs = [settings.ldap_attr_email, settings.ldap_attr_full_name]
found = finder.search(
search_base=settings.ldap_user_search_base,
search_filter=search_filter,
attributes=attrs,
)
if not found or not finder.entries:
return None
entry = finder.entries[0]
user_dn = entry.entry_dn
finally:
finder.unbind()
return _bind_and_read(server, user_dn, password, username, entry=entry)
except LDAPException:
# Network error, invalid DN template, etc. Fail closed.
return None
except (KeyError, IndexError, ValueError):
# e.g. a bad ``{username}`` template placeholder.
return None
def _bind_and_read(server, user_dn, password, username, entry=None):
"""Bind as ``user_dn`` to verify the password, then extract attributes."""
from ldap3 import Connection
from ldap3.core.exceptions import LDAPException
try:
conn = Connection(server, user=user_dn, password=password, auto_bind=True)
except LDAPException:
# Wrong password / bind rejected.
return None
try:
# Direct-bind mode has no prior search entry; read the attributes now.
if entry is None:
conn.search(
search_base=user_dn,
search_filter="(objectClass=*)",
search_scope="BASE",
attributes=[settings.ldap_attr_email, settings.ldap_attr_full_name],
)
entry = conn.entries[0] if conn.entries else None
finally:
conn.unbind()
email = _read_attr(entry, settings.ldap_attr_email) or _default_email(username)
full_name = _read_attr(entry, settings.ldap_attr_full_name)
return LdapUser(email=email.lower(), full_name=full_name)
def _read_attr(entry, name: str) -> str | None:
if entry is None:
return None
try:
value = entry[name].value
except (KeyError, LookupError):
return None
if isinstance(value, (list, tuple)):
value = value[0] if value else None
return str(value) if value else None
def _default_email(username: str) -> str:
"""Fallback identity when the directory entry exposes no email attribute."""
return username if "@" in username else f"{username}@ldap.local"
-6
View File
@@ -8,12 +8,6 @@
{% if user.is_superuser or "roles:read" in user.permissions %} {% if user.is_superuser or "roles:read" in user.permissions %}
<a href="/admin/roles">Roles</a> <a href="/admin/roles">Roles</a>
{% endif %} {% endif %}
{% if user.is_superuser or "iso_threads:read" in user.permissions %}
<a href="/admin/iso-threads">ISO Threads</a>
{% endif %}
{% if user.is_superuser or "unc_threads:read" in user.permissions %}
<a href="/admin/unc-threads">UNC Threads</a>
{% endif %}
<a href="/docs" target="_blank">API docs</a> <a href="/docs" target="_blank">API docs</a>
</nav> </nav>
<form method="post" action="/logout" class="logout"> <form method="post" action="/logout" class="logout">
-17
View File
@@ -1,17 +0,0 @@
<tr id="iso-thread-{{ t.id }}">
<td>{{ t.id }}</td>
<td>{{ t.diameter_mm }}</td>
<td>{{ t.pitch_mm }}</td>
<td>{{ t.depth_mm if t.depth_mm is not none else "—" }}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/iso-threads/{{ t.id }}/edit"
hx-target="#iso-thread-{{ t.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/iso-threads/{{ t.id }}"
hx-target="#iso-thread-{{ t.id }}"
hx-swap="outerHTML"
hx-confirm="Delete ISO thread #{{ t.id }}?">Delete</button>
</td>
</tr>
@@ -1,20 +0,0 @@
<tr id="iso-thread-{{ t.id }}">
<td colspan="5">
<form hx-post="/admin/iso-threads/{{ t.id }}"
hx-target="#iso-thread-{{ t.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ t.id }}</span>
<label>Diameter (mm) <input type="number" name="diameter_mm" step="0.001" min="0" value="{{ t.diameter_mm }}" required></label>
<label>Pitch (mm) <input type="number" name="pitch_mm" step="0.001" min="0" value="{{ t.pitch_mm }}" required></label>
<label>Depth (mm) <input type="number" name="depth_mm" step="0.001" min="0" value="{{ t.depth_mm if t.depth_mm is not none else '' }}" placeholder="optional"></label>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/iso-threads/{{ t.id }}/row"
hx-target="#iso-thread-{{ t.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
@@ -1,16 +0,0 @@
<div id="iso-thread-list" class="card">
<table class="table">
<thead>
<tr>
<th>ID</th><th>Diameter (mm)</th><th>Pitch (mm)</th><th>Depth (mm)</th><th></th>
</tr>
</thead>
<tbody>
{% for t in threads %}
{% include "admin/_iso_thread_row.html" %}
{% else %}
<tr><td colspan="5" class="muted">No threads recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
-17
View File
@@ -1,17 +0,0 @@
<tr id="role-{{ r.id }}">
<td>{{ r.id }}</td>
<td>{{ r.name }}</td>
<td>{{ r.description or "" }}</td>
<td>{% for perm in r.permissions %}<span class="tag">{{ perm.name }}</span>{% endfor %}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/roles/{{ r.id }}/edit"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
hx-confirm="Delete role {{ r.name }}?">Delete</button>
</td>
</tr>
-26
View File
@@ -1,26 +0,0 @@
<tr id="role-{{ r.id }}">
<td colspan="5">
<form hx-post="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ r.id }} · {{ r.name }}</span>
<fieldset class="roles-field">
<legend>Permissions</legend>
{% for perm in permissions %}
<label class="check">
<input type="checkbox" name="permission_ids" value="{{ perm.id }}"
{{ "checked" if perm in r.permissions }}> {{ perm.name }}
</label>
{% endfor %}
</fieldset>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/roles/{{ r.id }}/row"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
+13 -1
View File
@@ -5,7 +5,19 @@
</thead> </thead>
<tbody> <tbody>
{% for r in roles %} {% for r in roles %}
{% include "admin/_role_row.html" %} <tr id="role-{{ r.id }}">
<td>{{ r.id }}</td>
<td>{{ r.name }}</td>
<td>{{ r.description or "" }}</td>
<td>{% for perm in r.permissions %}<span class="tag">{{ perm.name }}</span>{% endfor %}</td>
<td class="row-actions">
<button class="btn btn-danger"
hx-delete="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
hx-confirm="Delete role {{ r.name }}?">Delete</button>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
-18
View File
@@ -1,18 +0,0 @@
<tr id="unc-thread-{{ t.id }}">
<td>{{ t.id }}</td>
<td>{{ t.diameter_fraction }}"</td>
<td>{{ t.tpi }}</td>
<td>{{ t.series.value }}</td>
<td>{{ t.depth_in if t.depth_in is not none else "—" }}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/unc-threads/{{ t.id }}/edit"
hx-target="#unc-thread-{{ t.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/unc-threads/{{ t.id }}"
hx-target="#unc-thread-{{ t.id }}"
hx-swap="outerHTML"
hx-confirm="Delete UNC thread #{{ t.id }}?">Delete</button>
</td>
</tr>
@@ -1,27 +0,0 @@
<tr id="unc-thread-{{ t.id }}">
<td colspan="6">
<form hx-post="/admin/unc-threads/{{ t.id }}"
hx-target="#unc-thread-{{ t.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ t.id }}</span>
<label>Diameter <input type="text" name="diameter_fraction" value="{{ t.diameter_fraction }}" required></label>
<label>TPI <input type="number" name="tpi" min="1" step="1" value="{{ t.tpi }}" required></label>
<label>Series
<select name="series">
{% for s in series_options %}
<option value="{{ s.value }}" {{ "selected" if s == t.series }}>{{ s.value }}</option>
{% endfor %}
</select>
</label>
<label>Depth (in) <input type="number" name="depth_in" step="0.001" min="0" value="{{ t.depth_in if t.depth_in is not none else '' }}" placeholder="optional"></label>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/unc-threads/{{ t.id }}/row"
hx-target="#unc-thread-{{ t.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
@@ -1,16 +0,0 @@
<div id="unc-thread-list" class="card">
<table class="table">
<thead>
<tr>
<th>ID</th><th>Diameter</th><th>TPI</th><th>Series</th><th>Depth (in)</th><th></th>
</tr>
</thead>
<tbody>
{% for t in threads %}
{% include "admin/_unc_thread_row.html" %}
{% else %}
<tr><td colspan="6" class="muted">No threads recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
-20
View File
@@ -1,20 +0,0 @@
{% extends "base.html" %}
{% block title %}ISO Threads · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>ISO Threads</h1>
<div class="card">
<h2>Add thread</h2>
<form hx-post="/admin/iso-threads" hx-target="#iso-thread-list" hx-swap="outerHTML" class="form-grid">
<label>Diameter (mm) <input type="number" name="diameter_mm" step="0.001" min="0" required></label>
<label>Pitch (mm) <input type="number" name="pitch_mm" step="0.001" min="0" required></label>
<label>Depth (mm) <input type="number" name="depth_mm" step="0.001" min="0" placeholder="optional"></label>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
{% include "admin/_iso_threads_table.html" %}
</main>
{% endblock %}
-27
View File
@@ -1,27 +0,0 @@
{% extends "base.html" %}
{% block title %}UNC Threads · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>UNC Threads</h1>
<div class="card">
<h2>Add thread</h2>
<form hx-post="/admin/unc-threads" hx-target="#unc-thread-list" hx-swap="outerHTML" class="form-grid">
<label>Diameter <input type="text" name="diameter_fraction" placeholder='e.g. 1/4, 3/8, 1-1/2' required></label>
<label>TPI <input type="number" name="tpi" min="1" step="1" required></label>
<label>Series
<select name="series">
{% for s in series_options %}
<option value="{{ s.value }}">{{ s.value }}</option>
{% endfor %}
</select>
</label>
<label>Depth (in) <input type="number" name="depth_in" step="0.001" min="0" placeholder="optional"></label>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
{% include "admin/_unc_threads_table.html" %}
</main>
{% endblock %}
+6
View File
@@ -6,9 +6,15 @@
<h1>{{ app_name }}</h1> <h1>{{ app_name }}</h1>
<p class="muted">Sign in to continue</p> <p class="muted">Sign in to continue</p>
{% if error %}<div class="alert">{{ error }}</div>{% endif %} {% if error %}<div class="alert">{{ error }}</div>{% endif %}
{% if ldap_enabled %}
<label>Email or username
<input type="text" name="email" value="{{ email or '' }}" required autofocus>
</label>
{% else %}
<label>Email <label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus> <input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label> </label>
{% endif %}
<label>Password <label>Password
<input type="password" name="password" required> <input type="password" name="password" required>
</label> </label>
+2 -277
View File
@@ -1,16 +1,13 @@
"""HTMX-driven admin dashboard for users, roles, and thread standards.""" """HTMX-driven admin dashboard for users and roles."""
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import iso_thread as iso_thread_crud
from app.crud import role as role_crud from app.crud import role as role_crud
from app.crud import unc_thread as unc_thread_crud
from app.crud import user as user_crud from app.crud import user as user_crud
from app.deps import get_current_web_user from app.deps import get_current_web_user
from app.models.unc_thread import UncSeries
from app.models.user import User from app.models.user import User
from app.web.templates import templates from app.web.templates import templates
@@ -197,58 +194,10 @@ async def create_role(
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_roles_table.html", "admin/_roles_table.html",
{ {"roles": await role_crud.list_roles(db)},
"roles": await role_crud.list_roles(db),
"permissions": await role_crud.list_permissions(db),
},
) )
@router.get("/roles/{role_id}/edit", response_class=HTMLResponse)
async def edit_role_row(
request: Request,
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
return templates.TemplateResponse(
request,
"admin/_role_row_edit.html",
{"r": role, "permissions": await role_crud.list_permissions(db)},
)
@router.get("/roles/{role_id}/row", response_class=HTMLResponse)
async def role_row(
request: Request,
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:read")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
return templates.TemplateResponse(request, "admin/_role_row.html", {"r": role})
@router.post("/roles/{role_id}", response_class=HTMLResponse)
async def update_role(
request: Request,
role_id: int,
permission_ids: list[int] = Form(default=[]),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
await role_crud.update(db, role, {"permission_ids": permission_ids})
return templates.TemplateResponse(request, "admin/_role_row.html", {"r": role})
@router.delete("/roles/{role_id}") @router.delete("/roles/{role_id}")
async def delete_role( async def delete_role(
role_id: int, role_id: int,
@@ -259,227 +208,3 @@ async def delete_role(
if role is not None: if role is not None:
await role_crud.delete(db, role) await role_crud.delete(db, role)
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
# --- ISO Threads -------------------------------------------------------------
@router.get("/iso-threads", response_class=HTMLResponse)
async def iso_threads_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("iso_threads:read")),
):
return templates.TemplateResponse(
request,
"admin/iso_threads.html",
{"user": user, "threads": await iso_thread_crud.list_all(db)},
)
@router.post("/iso-threads", response_class=HTMLResponse)
async def create_iso_thread(
request: Request,
diameter_mm: float = Form(...),
pitch_mm: float = Form(...),
depth_mm: str = Form(""),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("iso_threads:write")),
):
await iso_thread_crud.create(
db,
diameter_mm=diameter_mm,
pitch_mm=pitch_mm,
depth_mm=float(depth_mm) if depth_mm.strip() else None,
)
return templates.TemplateResponse(
request,
"admin/_iso_threads_table.html",
{"threads": await iso_thread_crud.list_all(db)},
)
@router.get("/iso-threads/{thread_id}/edit", response_class=HTMLResponse)
async def edit_iso_thread_row(
request: Request,
thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("iso_threads:write")),
):
thread = await iso_thread_crud.get(db, thread_id)
if thread is None:
raise HTTPException(status_code=404, detail="ISO thread not found")
return templates.TemplateResponse(
request, "admin/_iso_thread_row_edit.html", {"t": thread}
)
@router.get("/iso-threads/{thread_id}/row", response_class=HTMLResponse)
async def iso_thread_row(
request: Request,
thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("iso_threads:read")),
):
thread = await iso_thread_crud.get(db, thread_id)
if thread is None:
raise HTTPException(status_code=404, detail="ISO thread not found")
return templates.TemplateResponse(
request, "admin/_iso_thread_row.html", {"t": thread}
)
@router.post("/iso-threads/{thread_id}", response_class=HTMLResponse)
async def update_iso_thread(
request: Request,
thread_id: int,
diameter_mm: float = Form(...),
pitch_mm: float = Form(...),
depth_mm: str = Form(""),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("iso_threads:write")),
):
thread = await iso_thread_crud.get(db, thread_id)
if thread is None:
raise HTTPException(status_code=404, detail="ISO thread not found")
await iso_thread_crud.update(
db,
thread,
{
"diameter_mm": diameter_mm,
"pitch_mm": pitch_mm,
"depth_mm": float(depth_mm) if depth_mm.strip() else None,
},
)
return templates.TemplateResponse(
request, "admin/_iso_thread_row.html", {"t": thread}
)
@router.delete("/iso-threads/{thread_id}")
async def delete_iso_thread(
thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("iso_threads:delete")),
):
thread = await iso_thread_crud.get(db, thread_id)
if thread is not None:
await iso_thread_crud.delete(db, thread)
return Response(status_code=status.HTTP_200_OK)
# --- UNC Threads -------------------------------------------------------------
@router.get("/unc-threads", response_class=HTMLResponse)
async def unc_threads_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("unc_threads:read")),
):
return templates.TemplateResponse(
request,
"admin/unc_threads.html",
{
"user": user,
"threads": await unc_thread_crud.list_all(db),
"series_options": list(UncSeries),
},
)
@router.post("/unc-threads", response_class=HTMLResponse)
async def create_unc_thread(
request: Request,
diameter_fraction: str = Form(...),
tpi: int = Form(...),
series: UncSeries = Form(...),
depth_in: str = Form(""),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("unc_threads:write")),
):
await unc_thread_crud.create(
db,
diameter_fraction=diameter_fraction,
tpi=tpi,
series=series,
depth_in=float(depth_in) if depth_in.strip() else None,
)
return templates.TemplateResponse(
request,
"admin/_unc_threads_table.html",
{"threads": await unc_thread_crud.list_all(db)},
)
@router.get("/unc-threads/{thread_id}/edit", response_class=HTMLResponse)
async def edit_unc_thread_row(
request: Request,
thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("unc_threads:write")),
):
thread = await unc_thread_crud.get(db, thread_id)
if thread is None:
raise HTTPException(status_code=404, detail="UNC thread not found")
return templates.TemplateResponse(
request,
"admin/_unc_thread_row_edit.html",
{"t": thread, "series_options": list(UncSeries)},
)
@router.get("/unc-threads/{thread_id}/row", response_class=HTMLResponse)
async def unc_thread_row(
request: Request,
thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("unc_threads:read")),
):
thread = await unc_thread_crud.get(db, thread_id)
if thread is None:
raise HTTPException(status_code=404, detail="UNC thread not found")
return templates.TemplateResponse(
request, "admin/_unc_thread_row.html", {"t": thread}
)
@router.post("/unc-threads/{thread_id}", response_class=HTMLResponse)
async def update_unc_thread(
request: Request,
thread_id: int,
diameter_fraction: str = Form(...),
tpi: int = Form(...),
series: UncSeries = Form(...),
depth_in: str = Form(""),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("unc_threads:write")),
):
thread = await unc_thread_crud.get(db, thread_id)
if thread is None:
raise HTTPException(status_code=404, detail="UNC thread not found")
await unc_thread_crud.update(
db,
thread,
{
"diameter_fraction": diameter_fraction,
"tpi": tpi,
"series": series,
"depth_in": float(depth_in) if depth_in.strip() else None,
},
)
return templates.TemplateResponse(
request, "admin/_unc_thread_row.html", {"t": thread}
)
@router.delete("/unc-threads/{thread_id}")
async def delete_unc_thread(
thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("unc_threads:delete")),
):
thread = await unc_thread_crud.get(db, thread_id)
if thread is not None:
await unc_thread_crud.delete(db, thread)
return Response(status_code=status.HTTP_200_OK)
+10 -5
View File
@@ -4,10 +4,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.crud import user as user_crud
from app.deps import get_current_web_user from app.deps import get_current_web_user
from app.models.user import User from app.models.user import User
from app.services.auth import issue_token_pair from app.services.auth import authenticate, issue_token_pair
from app.web.templates import templates from app.web.templates import templates
router = APIRouter(include_in_schema=False) router = APIRouter(include_in_schema=False)
@@ -35,7 +34,9 @@ async def dashboard(
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
return templates.TemplateResponse(request, "login.html", {}) return templates.TemplateResponse(
request, "login.html", {"ldap_enabled": settings.ldap_enabled}
)
@router.post("/login") @router.post("/login")
@@ -45,13 +46,17 @@ async def login_submit(
password: str = Form(...), password: str = Form(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
user = await user_crud.authenticate(db, email, password) user = await authenticate(db, email, password)
if user is None: if user is None:
# Re-render the form with an error (HTMX swaps the form partial). # Re-render the form with an error (HTMX swaps the form partial).
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"login.html", "login.html",
{"error": "Incorrect email or password", "email": email}, {
"error": "Incorrect email or password",
"email": email,
"ldap_enabled": settings.ldap_enabled,
},
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
) )
access, _ = await issue_token_pair(db, user) access, _ = await issue_token_pair(db, user)
-3
View File
@@ -1,3 +0,0 @@
[tools]
python = "latest"
uv = "latest"
+3 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "fitguide" name = "fastapi-skeleton"
version = "0.1.0" version = "0.1.0"
description = "Fitguide for Armament" description = "FastAPI + HTMX skeleton with async SQLAlchemy, RBAC, JWT auth, and an admin dashboard."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -17,6 +17,7 @@ dependencies = [
"pydantic-settings>=2.3", "pydantic-settings>=2.3",
"pyjwt>=2.8", "pyjwt>=2.8",
"bcrypt>=4.1", "bcrypt>=4.1",
"ldap3>=2.9",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+1 -7
View File
@@ -21,18 +21,12 @@ PERMISSIONS = [
("users:delete", "Delete users"), ("users:delete", "Delete users"),
("roles:read", "View roles and permissions"), ("roles:read", "View roles and permissions"),
("roles:write", "Create, update and delete roles"), ("roles:write", "Create, update and delete roles"),
("iso_threads:read", "View ISO threads"),
("iso_threads:write", "Create and update ISO threads"),
("iso_threads:delete", "Delete ISO threads"),
("unc_threads:read", "View UNC threads"),
("unc_threads:write", "Create and update UNC threads"),
("unc_threads:delete", "Delete UNC threads"),
] ]
# Default roles -> permission names. "admin" gets everything. # Default roles -> permission names. "admin" gets everything.
ROLES = { ROLES = {
"admin": [name for name, _ in PERMISSIONS], "admin": [name for name, _ in PERMISSIONS],
"user": ["users:read", "iso_threads:read", "unc_threads:read"], "user": ["users:read"],
} }
+99
View File
@@ -0,0 +1,99 @@
"""LDAP authentication tests.
The blocking directory bind (``_authenticate_sync``) is replaced with a fake so
no real LDAP server is needed; everything above it — the enabled gate, the
threadpool hop, JIT provisioning, and local fallback — runs for real.
"""
import pytest
from app.core.config import settings
from app.crud import user as user_crud
from app.services import ldap as ldap_service
DIRECTORY = {
# login name -> (password, email, full_name)
"jdoe": ("ldap-pass", "jdoe@corp.example", "Jane Doe"),
}
def _fake_sync(username: str, password: str):
record = DIRECTORY.get(username)
if record is None or record[0] != password:
return None
_, email, full_name = record
return ldap_service.LdapUser(email=email.lower(), full_name=full_name)
@pytest.fixture
def ldap_enabled(monkeypatch):
monkeypatch.setattr(settings, "ldap_enabled", True)
monkeypatch.setattr(settings, "ldap_default_roles", ["user"])
monkeypatch.setattr(ldap_service, "_authenticate_sync", _fake_sync)
async def test_ldap_login_provisions_local_user(
client, seeded, db_sessionmaker, ldap_enabled
):
resp = await client.post(
"/api/auth/login",
data={"username": "jdoe", "password": "ldap-pass"},
)
assert resp.status_code == 200, resp.text
access = resp.json()["access_token"]
# A local row was created from the directory entry.
async with db_sessionmaker() as db:
user = await user_crud.get_by_email(db, "jdoe@corp.example")
assert user is not None
assert user.auth_provider == "ldap"
assert user.full_name == "Jane Doe"
assert "user" in {r.name for r in user.roles} # default role applied
# The issued token authenticates against the API.
me = await client.get(
"/api/users/me", headers={"Authorization": f"Bearer {access}"}
)
assert me.status_code == 200
assert me.json()["email"] == "jdoe@corp.example"
assert me.json()["auth_provider"] == "ldap"
async def test_ldap_wrong_password_rejected(client, seeded, ldap_enabled):
resp = await client.post(
"/api/auth/login",
data={"username": "jdoe", "password": "nope"},
)
assert resp.status_code == 401
async def test_ldap_user_cannot_use_local_password(
client, seeded, db_sessionmaker, ldap_enabled
):
# Provision via LDAP first.
await client.post(
"/api/auth/login", data={"username": "jdoe", "password": "ldap-pass"}
)
# The local password slot is unusable, so DB auth must fail for this user
# even though the account now exists.
async with db_sessionmaker() as db:
assert (
await user_crud.authenticate(db, "jdoe@corp.example", "ldap-pass") is None
)
async def test_local_user_still_logs_in_when_ldap_enabled(
client, seeded, make_user, ldap_enabled
):
# A local account that does NOT exist in the directory falls back to DB auth.
await make_user("local@example.com", password="secret123")
resp = await client.post(
"/api/auth/login",
data={"username": "local@example.com", "password": "secret123"},
)
assert resp.status_code == 200, resp.text
async def test_empty_password_is_rejected(ldap_enabled):
# Guards against an anonymous bind masquerading as a successful login.
assert await ldap_service.authenticate_ldap("jdoe", "") is None
Generated
+24 -1
View File
@@ -280,7 +280,7 @@ wheels = [
] ]
[[package]] [[package]]
name = "fitguide" name = "fastapi-skeleton"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
@@ -290,6 +290,7 @@ dependencies = [
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "ldap3" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
@@ -317,6 +318,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.111" }, { name = "fastapi", specifier = ">=0.111" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "jinja2", specifier = ">=3.1" }, { name = "jinja2", specifier = ">=3.1" },
{ name = "ldap3", specifier = ">=2.9" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "pydantic", extras = ["email"], specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.3" }, { name = "pydantic-settings", specifier = ">=2.3" },
{ name = "pyjwt", specifier = ">=2.8" }, { name = "pyjwt", specifier = ">=2.8" },
@@ -516,6 +518,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
] ]
[[package]]
name = "ldap3"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830, upload-time = "2021-07-18T06:34:21.786Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.12" version = "1.3.12"
@@ -647,6 +661,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"