diff --git a/API.md b/API.md new file mode 100644 index 0000000..4969993 --- /dev/null +++ b/API.md @@ -0,0 +1,312 @@ +# Howl REST API + +Howl exposes a JSON REST API at `/api/v1/` for programmatic access by external services such as OpenClaw. All endpoints require a Bearer token. + +## Authentication + +Set one or more API keys in `.env`: + +```env +API_KEYS=your-key-here,another-key +``` + +Include the key in every request: + +``` +Authorization: Bearer your-key-here +``` + +Requests without a valid key return `401 Unauthorized`. + +--- + +## Endpoints + +### Email Processing + +#### `POST /api/v1/process` + +Trigger a pipeline poll — fetches unread email from the last 14 days and runs the full classification pipeline. + +**Response** +```json +{ "processed": 12 } +``` + +--- + +### Email Log + +#### `GET /api/v1/log` + +Query the email processing log. + +**Query parameters** + +| Parameter | Type | Description | +|---|---|---| +| `status` | string | Filter by status: `pending`, `processing`, `completed`, `failed`, `skipped` | +| `sender_type` | string | Filter by sender type: `customer`, `vendor`, `whitelist`, `unknown` | +| `classification` | string | Filter by LLM classification (e.g. `vendor_invoice`) | +| `q` | string | Search sender address or subject (case-insensitive) | +| `limit` | int | Results per page, max 200 (default 50) | +| `offset` | int | Pagination offset (default 0) | + +**Response** +```json +{ + "total": 847, + "items": [ + { + "id": "...", + "sender_address": "billing@vendor.com", + "subject": "Invoice #1042", + "llm_classification": "vendor_invoice", + "llm_confidence": 0.97, + "final_action": "flag_follow_up", + "status": "completed", + ... + } + ] +} +``` + +#### `GET /api/v1/log/{entry_id}` + +Retrieve a single log entry by UUID. + +--- + +### Sender Profiles + +Sender profiles guide the LLM by providing context about known senders. + +#### `GET /api/v1/senders` + +List all active sender profiles. + +**Response**: `{ "items": [...] }` + +#### `POST /api/v1/senders` + +Create or update a sender profile (upserts by `email_address`). + +**Request body** +```json +{ + "email_address": "billing@vendor.com", + "display_name": "Vendor Billing", + "sender_type": "vendor", + "email_types": ["invoice", "statement"], + "processing_instructions": "Always flag for follow-up", + "notes": null +} +``` + +`sender_type` values: `customer`, `vendor`, `whitelist`, `unknown` + +#### `GET /api/v1/senders/{profile_id}` + +Retrieve a single profile by UUID. + +#### `PUT /api/v1/senders/{profile_id}` + +Replace a profile's fields (same body as POST). + +#### `DELETE /api/v1/senders/{profile_id}` + +Deactivate a profile (soft delete). + +--- + +### Purge Rules + +Purge rules define senders whose mail should be deleted after a retention period. + +#### `GET /api/v1/purge-rules` + +List all purge rules (active and inactive). + +#### `POST /api/v1/purge-rules` + +Create or reactivate a purge rule. + +**Request body** +```json +{ + "email_address": "newsletter@bulk.com", + "display_name": "Bulk Newsletter", + "older_than_days": 14, + "notes": null +} +``` + +#### `PUT /api/v1/purge-rules/{rule_id}` + +Partially update a rule. All fields are optional. + +```json +{ "older_than_days": 7, "is_active": false } +``` + +#### `DELETE /api/v1/purge-rules/{rule_id}` + +Deactivate a rule (soft delete). + +--- + +### Purge Execution + +#### `POST /api/v1/purge/execute` + +Preview or execute a purge run against Microsoft 365. + +**Request body** +```json +{ + "mode": "preview", + "sender": "newsletter@bulk.com", + "days": 30 +} +``` + +| Field | Values | Description | +|---|---|---| +| `mode` | `preview` \| `execute` | `preview` reports what would be deleted without deleting | +| `sender` | string or `null` | Specific sender address; `null` uses all active rules | +| `days` | int | Retention threshold in days (only applies when `sender` is set) | + +**Response** +```json +{ + "mode": "preview", + "total": 47, + "rows": [ + { + "address": "newsletter@bulk.com", + "label": "newsletter@bulk.com (>30d)", + "count": 47, + "subjects": ["Weekly digest", "Monthly digest", "Special offer"] + } + ] +} +``` + +--- + +### Analysis + +#### `GET /api/v1/analysis/senders` + +Aggregated sender statistics from the email log (top 200 by volume). + +**Response** +```json +{ + "items": [ + { + "sender_address": "billing@vendor.com", + "count": 143, + "first_seen": "2025-01-03T10:22:00+00:00", + "last_seen": "2026-03-28T14:05:00+00:00" + } + ] +} +``` + +--- + +### Whitelist + +#### `GET /api/v1/whitelist` + +List all active whitelist entries. + +#### `POST /api/v1/whitelist` + +Add a whitelist entry. At least one of `email_address` or `domain` is required. + +```json +{ + "email_address": "ceo@partner.com", + "domain": null, + "description": "Executive contact", + "added_by": "openclaw", + "expires_at": null +} +``` + +#### `DELETE /api/v1/whitelist/{entry_id}` + +Deactivate a whitelist entry. + +--- + +## DB Users + +The `/dbusers` web UI (available in the Howl dashboard sidebar) lets you create PostgreSQL roles for direct database access. This is intended for services that need to query Howl's database directly rather than going through the API. + +### Permission Levels + +| Level | Permissions | +|---|---| +| **Read Only** | `SELECT` on all tables | +| **Modify** | `SELECT` + `INSERT`/`UPDATE` on `customers`, `vendors`, `whitelist`, `sender_profiles`, `purge_rules` | +| **Full** | `SELECT` + `INSERT`/`UPDATE`/`DELETE` on all tables | + +### Usage + +1. Navigate to **DB Users** in the sidebar +2. Click **+ Create User** +3. Enter a username (3–31 chars, lowercase, letters/digits/underscores) — the role will be created as `howl_` +4. Select a permission level +5. Optionally add a description (e.g. "OpenClaw integration") +6. Click **Create User** — credentials are shown **once** and never stored + +To revoke access, click **Revoke** next to the user. This sets the user inactive in Howl and drops the PostgreSQL role. + +### Connection details + +``` +Host: localhost (or the host running Postgres) +Port: 5434 +Database: howl +Username: howl_ +Password: (shown at creation time) +``` + +--- + +## Example: OpenClaw integration + +```python +import httpx + +HOWL_BASE = "https://howl.wulfconsulting.cloud" +HOWL_KEY = "your-api-key" + +headers = {"Authorization": f"Bearer {HOWL_KEY}"} + +# Trigger a mail fetch +httpx.post(f"{HOWL_BASE}/api/v1/process", headers=headers) + +# Pull recent completed entries +resp = httpx.get( + f"{HOWL_BASE}/api/v1/log", + headers=headers, + params={"status": "completed", "limit": 100}, +) +entries = resp.json()["items"] + +# Add a sender profile +httpx.post( + f"{HOWL_BASE}/api/v1/senders", + headers=headers, + json={ + "email_address": "alerts@vendor.com", + "sender_type": "vendor", + "email_types": ["notification"], + "processing_instructions": "Route to Infosec if subject contains CVE", + }, +) +``` diff --git a/howl/config.py b/howl/config.py index 0324ece..346b704 100644 --- a/howl/config.py +++ b/howl/config.py @@ -49,6 +49,9 @@ class Settings(BaseSettings): folder_infosec: str = "Infosec" + # API — comma-separated list of valid Bearer tokens (e.g. API_KEYS=key1,key2) + api_keys: str = "" + # Web UI web_host: str = "127.0.0.1" web_port: int = 8080 diff --git a/howl/db/models.py b/howl/db/models.py index f42ace1..124a3f8 100644 --- a/howl/db/models.py +++ b/howl/db/models.py @@ -199,6 +199,28 @@ class SenderProfile(Base): ) +DB_PERMISSION_LEVEL_ENUM = Enum( + "read_only", "modify", "full", name="db_permission_level", create_type=False +) + + +# --------------------------------------------------------------------------- +# DB API Users +# --------------------------------------------------------------------------- + + +class DbApiUser(Base): + __tablename__ = "db_api_users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + username: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + permission_level: Mapped[str] = mapped_column(DB_PERMISSION_LEVEL_ENUM, nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + # --------------------------------------------------------------------------- # Email Log # --------------------------------------------------------------------------- diff --git a/howl/web/app.py b/howl/web/app.py index ddcd58a..e3f3abc 100644 --- a/howl/web/app.py +++ b/howl/web/app.py @@ -7,7 +7,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from howl.web.deps import templates # noqa: F401 — registers template dir -from howl.web.routes import analysis, dashboard, fetch, log, purge, runner, senders +from howl.web.routes import analysis, api, dashboard, dbusers, fetch, log, purge, runner, senders STATIC_DIR = Path(__file__).parent / "static" @@ -36,5 +36,7 @@ def create_app() -> FastAPI: app.include_router(analysis.router) app.include_router(runner.router) app.include_router(senders.router) + app.include_router(api.router) + app.include_router(dbusers.router) return app diff --git a/howl/web/deps.py b/howl/web/deps.py index 8ebc2c0..334564f 100644 --- a/howl/web/deps.py +++ b/howl/web/deps.py @@ -4,6 +4,8 @@ import json from pathlib import Path from typing import AsyncGenerator +from fastapi import HTTPException, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.templating import Jinja2Templates from sqlalchemy.ext.asyncio import AsyncSession @@ -76,3 +78,18 @@ EMAIL_TYPE_OPTIONS: list[tuple[str, str]] = [ async def get_db() -> AsyncGenerator[AsyncSession, None]: async with async_session() as session: yield session + + +_bearer = HTTPBearer(auto_error=False) + + +async def require_api_key( + credentials: HTTPAuthorizationCredentials | None = Security(_bearer), +) -> str: + from howl.config import get_settings + + settings = get_settings() + valid_keys = [k.strip() for k in settings.api_keys.split(",") if k.strip()] + if not credentials or credentials.credentials not in valid_keys: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + return credentials.credentials diff --git a/howl/web/routes/api.py b/howl/web/routes/api.py new file mode 100644 index 0000000..395cdd5 --- /dev/null +++ b/howl/web/routes/api.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime +from typing import Annotated, Any, Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from howl.db.models import EmailLog, PurgeRule, SenderProfile, Whitelist +from howl.web.deps import get_db, require_api_key + +router = APIRouter(prefix="/api/v1", tags=["api"]) + +_SENDER_TYPES = ("customer", "vendor", "whitelist", "unknown") + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + + +class LogEntryOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + graph_message_id: str + mailbox: str + sender_address: str + sender_name: Optional[str] + subject: Optional[str] + received_at: Optional[datetime] + has_attachments: bool + sender_type: str + llm_classification: Optional[str] + llm_confidence: Optional[float] + llm_reasoning: Optional[str] + llm_suggested_action: Optional[str] + final_action: str + action_overridden: bool + action_executed_at: Optional[datetime] + action_error: Optional[str] + status: str + retry_count: int + error_message: Optional[str] + created_at: datetime + updated_at: datetime + + +class SenderProfileOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email_address: str + display_name: Optional[str] + sender_type: str + email_types: list + processing_instructions: Optional[str] + notes: Optional[str] + is_active: bool + created_at: datetime + updated_at: datetime + + +class SenderProfileIn(BaseModel): + email_address: str + display_name: Optional[str] = None + sender_type: str = "unknown" + email_types: list[str] = [] + processing_instructions: Optional[str] = None + notes: Optional[str] = None + + +class PurgeRuleOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email_address: str + display_name: Optional[str] + older_than_days: int + is_active: bool + notes: Optional[str] + created_at: datetime + + +class PurgeRuleIn(BaseModel): + email_address: str + display_name: Optional[str] = None + older_than_days: int = 30 + notes: Optional[str] = None + + +class PurgeRuleUpdate(BaseModel): + display_name: Optional[str] = None + older_than_days: Optional[int] = None + notes: Optional[str] = None + is_active: Optional[bool] = None + + +class WhitelistOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email_address: Optional[str] + domain: Optional[str] + description: Optional[str] + added_by: Optional[str] + expires_at: Optional[datetime] + is_active: bool + created_at: datetime + + +class WhitelistIn(BaseModel): + email_address: Optional[str] = None + domain: Optional[str] = None + description: Optional[str] = None + added_by: Optional[str] = None + expires_at: Optional[datetime] = None + + +class PurgeExecuteIn(BaseModel): + mode: Literal["preview", "execute"] = "preview" + sender: Optional[str] = None + days: int = 30 + + +def _uuid_str(v: Any) -> Any: + if isinstance(v, uuid.UUID): + return str(v) + return v + + +# --------------------------------------------------------------------------- +# Email processing +# --------------------------------------------------------------------------- + + +@router.post("/process") +async def api_process( + _key: str = Depends(require_api_key), +) -> dict: + """Trigger a pipeline poll for unread mail.""" + from datetime import timedelta, timezone + + from howl.web.routes.fetch import _build_processor + + since = datetime.now(timezone.utc) - timedelta(days=14) + graph = None + try: + processor, graph = _build_processor() + processed = await processor.poll_once(received_after=since) + finally: + if graph is not None: + graph.close() + return {"processed": processed} + + +# --------------------------------------------------------------------------- +# Email log +# --------------------------------------------------------------------------- + + +@router.get("/log") +async def api_log_list( + status: Optional[str] = Query(None), + sender_type: Optional[str] = Query(None), + classification: Optional[str] = Query(None), + q: Optional[str] = Query(None), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + stmt = select(EmailLog) + if status: + stmt = stmt.where(EmailLog.status == status) + if sender_type: + stmt = stmt.where(EmailLog.sender_type == sender_type) + if classification: + stmt = stmt.where(EmailLog.llm_classification == classification) + if q: + like = f"%{q.lower()}%" + stmt = stmt.where( + func.lower(EmailLog.sender_address).like(like) + | func.lower(EmailLog.subject).like(like) + ) + + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar_one() + + stmt = stmt.order_by(EmailLog.created_at.desc()).offset(offset).limit(limit) + result = await db.execute(stmt) + items = [LogEntryOut.model_validate(row).model_dump(mode="json") for row in result.scalars()] + return {"total": total, "items": items} + + +@router.get("/log/{entry_id}") +async def api_log_get( + entry_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + entry = await db.get(EmailLog, entry_id) + if not entry: + raise HTTPException(status_code=404, detail="Log entry not found") + return LogEntryOut.model_validate(entry).model_dump(mode="json") + + +# --------------------------------------------------------------------------- +# Sender profiles +# --------------------------------------------------------------------------- + + +@router.get("/senders") +async def api_senders_list( + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + result = await db.execute( + select(SenderProfile).where(SenderProfile.is_active == True).order_by(SenderProfile.updated_at.desc()) + ) + items = [SenderProfileOut.model_validate(p).model_dump(mode="json") for p in result.scalars()] + return {"items": items} + + +@router.post("/senders", status_code=201) +async def api_senders_create( + body: SenderProfileIn, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + addr = body.email_address.lower().strip() + existing = await db.scalar( + select(SenderProfile).where(func.lower(SenderProfile.email_address) == addr) + ) + sender_type = body.sender_type if body.sender_type in _SENDER_TYPES else "unknown" + if existing: + existing.display_name = body.display_name + existing.sender_type = sender_type + existing.email_types = body.email_types + existing.processing_instructions = body.processing_instructions + existing.notes = body.notes + existing.is_active = True + profile = existing + else: + profile = SenderProfile( + email_address=addr, + display_name=body.display_name, + sender_type=sender_type, + email_types=body.email_types, + processing_instructions=body.processing_instructions, + notes=body.notes, + ) + db.add(profile) + await db.flush() + return SenderProfileOut.model_validate(profile).model_dump(mode="json") + + +@router.get("/senders/{profile_id}") +async def api_senders_get( + profile_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + profile = await db.get(SenderProfile, profile_id) + if not profile: + raise HTTPException(status_code=404, detail="Sender profile not found") + return SenderProfileOut.model_validate(profile).model_dump(mode="json") + + +@router.put("/senders/{profile_id}") +async def api_senders_update( + profile_id: uuid.UUID, + body: SenderProfileIn, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + profile = await db.get(SenderProfile, profile_id) + if not profile: + raise HTTPException(status_code=404, detail="Sender profile not found") + profile.email_address = body.email_address.lower().strip() + profile.display_name = body.display_name + profile.sender_type = body.sender_type if body.sender_type in _SENDER_TYPES else "unknown" + profile.email_types = body.email_types + profile.processing_instructions = body.processing_instructions + profile.notes = body.notes + await db.flush() + return SenderProfileOut.model_validate(profile).model_dump(mode="json") + + +@router.delete("/senders/{profile_id}", status_code=204) +async def api_senders_delete( + profile_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> None: + profile = await db.get(SenderProfile, profile_id) + if not profile: + raise HTTPException(status_code=404, detail="Sender profile not found") + profile.is_active = False + await db.flush() + + +# --------------------------------------------------------------------------- +# Purge rules +# --------------------------------------------------------------------------- + + +@router.get("/purge-rules") +async def api_purge_rules_list( + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + result = await db.execute(select(PurgeRule).order_by(PurgeRule.created_at.desc())) + items = [PurgeRuleOut.model_validate(r).model_dump(mode="json") for r in result.scalars()] + return {"items": items} + + +@router.post("/purge-rules", status_code=201) +async def api_purge_rules_create( + body: PurgeRuleIn, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + addr = body.email_address.lower().strip() + existing = await db.scalar( + select(PurgeRule).where(func.lower(PurgeRule.email_address) == addr) + ) + if existing: + existing.display_name = body.display_name + existing.older_than_days = body.older_than_days + existing.notes = body.notes + existing.is_active = True + rule = existing + else: + rule = PurgeRule( + email_address=addr, + display_name=body.display_name, + older_than_days=body.older_than_days, + notes=body.notes, + ) + db.add(rule) + await db.flush() + return PurgeRuleOut.model_validate(rule).model_dump(mode="json") + + +@router.put("/purge-rules/{rule_id}") +async def api_purge_rules_update( + rule_id: uuid.UUID, + body: PurgeRuleUpdate, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + rule = await db.get(PurgeRule, rule_id) + if not rule: + raise HTTPException(status_code=404, detail="Purge rule not found") + if body.display_name is not None: + rule.display_name = body.display_name + if body.older_than_days is not None: + rule.older_than_days = body.older_than_days + if body.notes is not None: + rule.notes = body.notes + if body.is_active is not None: + rule.is_active = body.is_active + await db.flush() + return PurgeRuleOut.model_validate(rule).model_dump(mode="json") + + +@router.delete("/purge-rules/{rule_id}", status_code=204) +async def api_purge_rules_delete( + rule_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> None: + rule = await db.get(PurgeRule, rule_id) + if not rule: + raise HTTPException(status_code=404, detail="Purge rule not found") + rule.is_active = False + await db.flush() + + +# --------------------------------------------------------------------------- +# Purge execution +# --------------------------------------------------------------------------- + + +@router.post("/purge/execute") +async def api_purge_execute( + body: PurgeExecuteIn, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + from datetime import timedelta, timezone + + from howl.web.routes.runner import _run_purge + + now = datetime.now(timezone.utc) + + if body.sender: + addr = body.sender.lower().strip() + rules = [(addr, now - timedelta(days=body.days))] + rule_labels = {addr: f"{addr} (>{body.days}d)"} + else: + result = await db.execute(select(PurgeRule).where(PurgeRule.is_active == True)) + active_rules = list(result.scalars()) + if not active_rules: + return {"mode": body.mode, "total": 0, "rows": []} + rules = [(r.email_address, now - timedelta(days=r.older_than_days)) for r in active_rules] + rule_labels = { + r.email_address: f"{r.display_name or r.email_address} (>{r.older_than_days}d)" + for r in active_rules + } + + dry_run = body.mode == "preview" + rows, total = await asyncio.to_thread(_run_purge, rules, rule_labels, dry_run) + return {"mode": body.mode, "total": total, "rows": rows} + + +# --------------------------------------------------------------------------- +# Analysis — sender stats from DB +# --------------------------------------------------------------------------- + + +@router.get("/analysis/senders") +async def api_analysis_senders( + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + result = await db.execute( + select( + EmailLog.sender_address, + func.count(EmailLog.id).label("count"), + func.min(EmailLog.received_at).label("first_seen"), + func.max(EmailLog.received_at).label("last_seen"), + ) + .group_by(EmailLog.sender_address) + .order_by(func.count(EmailLog.id).desc()) + .limit(200) + ) + rows = [ + { + "sender_address": r.sender_address, + "count": r.count, + "first_seen": r.first_seen.isoformat() if r.first_seen else None, + "last_seen": r.last_seen.isoformat() if r.last_seen else None, + } + for r in result + ] + return {"items": rows} + + +# --------------------------------------------------------------------------- +# Whitelist +# --------------------------------------------------------------------------- + + +@router.get("/whitelist") +async def api_whitelist_list( + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + result = await db.execute( + select(Whitelist).where(Whitelist.is_active == True).order_by(Whitelist.created_at.desc()) + ) + items = [WhitelistOut.model_validate(w).model_dump(mode="json") for w in result.scalars()] + return {"items": items} + + +@router.post("/whitelist", status_code=201) +async def api_whitelist_create( + body: WhitelistIn, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> dict: + if not body.email_address and not body.domain: + raise HTTPException(status_code=422, detail="At least one of email_address or domain is required") + entry = Whitelist( + email_address=body.email_address, + domain=body.domain, + description=body.description, + added_by=body.added_by, + expires_at=body.expires_at, + ) + db.add(entry) + await db.flush() + return WhitelistOut.model_validate(entry).model_dump(mode="json") + + +@router.delete("/whitelist/{entry_id}", status_code=204) +async def api_whitelist_delete( + entry_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + _key: str = Depends(require_api_key), +) -> None: + entry = await db.get(Whitelist, entry_id) + if not entry: + raise HTTPException(status_code=404, detail="Whitelist entry not found") + entry.is_active = False + await db.flush() diff --git a/howl/web/routes/dbusers.py b/howl/web/routes/dbusers.py new file mode 100644 index 0000000..5133615 --- /dev/null +++ b/howl/web/routes/dbusers.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import re +import secrets +import uuid +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from howl.db.models import DbApiUser +from howl.web.deps import get_db, templates + +router = APIRouter(prefix="/dbusers") + +_PERMISSION_LEVELS = ("read_only", "modify", "full") +_PERMISSION_LABELS = { + "read_only": "Read Only", + "modify": "Modify", + "full": "Full Access", +} +_USERNAME_RE = re.compile(r"^[a-z][a-z0-9_]{2,30}$") + + +def _db_name_from_url(database_url: str) -> str: + """Extract the database name from a SQLAlchemy connection URL.""" + path = database_url.split("/")[-1] + return path.split("?")[0] + + +async def _run_ddl(statements: list[str], database_url: str) -> None: + """Execute DDL statements using a raw asyncpg connection (autocommit).""" + from howl.db.engine import _engine + + async with _engine.connect() as conn: + raw = await conn.get_raw_connection() + pg_conn = raw.driver_connection + for stmt in statements: + await pg_conn.execute(stmt) + + +# --------------------------------------------------------------------------- +# List +# --------------------------------------------------------------------------- + + +@router.get("", response_class=HTMLResponse) +async def dbusers_list(request: Request, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(DbApiUser).order_by(DbApiUser.created_at.desc())) + users = list(result.scalars()) + return templates.TemplateResponse( + request, + "dbusers.html", + { + "active_page": "dbusers", + "users": users, + "permission_labels": _PERMISSION_LABELS, + "permission_levels": _PERMISSION_LEVELS, + }, + ) + + +# --------------------------------------------------------------------------- +# Form fragment (HTMX) +# --------------------------------------------------------------------------- + + +@router.get("/form", response_class=HTMLResponse) +async def dbusers_form(request: Request): + return templates.TemplateResponse( + request, + "dbuser_form.html", + {"permission_levels": _PERMISSION_LEVELS, "permission_labels": _PERMISSION_LABELS}, + ) + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + + +@router.post("", response_class=HTMLResponse) +async def dbusers_create( + request: Request, + username: Annotated[str, Form()], + permission_level: Annotated[str, Form()], + description: Annotated[str, Form()] = "", + db: AsyncSession = Depends(get_db), +): + from howl.config import get_settings + + # Validate inputs + if not _USERNAME_RE.match(username): + return templates.TemplateResponse( + request, + "dbuser_form.html", + { + "error": "Username must be 3–31 lowercase letters, digits, or underscores, starting with a letter.", + "permission_levels": _PERMISSION_LEVELS, + "permission_labels": _PERMISSION_LABELS, + "prefill_username": username, + "prefill_permission": permission_level, + "prefill_description": description, + }, + ) + + if permission_level not in _PERMISSION_LEVELS: + return templates.TemplateResponse( + request, + "dbuser_form.html", + { + "error": "Invalid permission level.", + "permission_levels": _PERMISSION_LEVELS, + "permission_labels": _PERMISSION_LABELS, + }, + ) + + settings = get_settings() + full_username = f"howl_{username}" + password = secrets.token_urlsafe(24) + db_name = _db_name_from_url(settings.database_url) + + # Build DDL statements + ddl: list[str] = [ + f"CREATE ROLE \"{full_username}\" WITH LOGIN PASSWORD '{password}'", + f'GRANT CONNECT ON DATABASE "{db_name}" TO "{full_username}"', + f'GRANT USAGE ON SCHEMA public TO "{full_username}"', + f'GRANT SELECT ON ALL TABLES IN SCHEMA public TO "{full_username}"', + ] + if permission_level == "modify": + ddl.append( + f'GRANT INSERT, UPDATE ON customers, vendors, whitelist, sender_profiles, purge_rules TO "{full_username}"' + ) + elif permission_level == "full": + ddl.append( + f'GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "{full_username}"' + ) + + try: + await _run_ddl(ddl, settings.database_url) + except Exception as exc: + return templates.TemplateResponse( + request, + "dbuser_form.html", + { + "error": f"Failed to create PostgreSQL role: {exc}", + "permission_levels": _PERMISSION_LEVELS, + "permission_labels": _PERMISSION_LABELS, + "prefill_username": username, + "prefill_permission": permission_level, + "prefill_description": description, + }, + ) + + # Insert tracking row + user = DbApiUser( + username=full_username, + permission_level=permission_level, + description=description.strip() or None, + ) + db.add(user) + await db.flush() + + response = templates.TemplateResponse( + request, + "dbuser_credentials_modal.html", + { + "username": full_username, + "password": password, + "permission_label": _PERMISSION_LABELS[permission_level], + }, + ) + response.headers["HX-Trigger"] = "dbUserCreated" + return response + + +# --------------------------------------------------------------------------- +# Delete / deactivate +# --------------------------------------------------------------------------- + + +@router.delete("/{user_id}", response_class=HTMLResponse) +async def dbusers_delete( + request: Request, + user_id: uuid.UUID, + db: AsyncSession = Depends(get_db), +): + user = await db.get(DbApiUser, user_id) + if not user: + return HTMLResponse("", status_code=404) + + user.is_active = False + await db.flush() + + drop_error: Optional[str] = None + try: + from howl.config import get_settings + + settings = get_settings() + await _run_ddl( + [f'DROP ROLE IF EXISTS "{user.username}"'], + settings.database_url, + ) + except Exception as exc: + drop_error = str(exc) + + return templates.TemplateResponse( + request, + "dbuser_row.html", + { + "user": user, + "permission_labels": _PERMISSION_LABELS, + "drop_error": drop_error, + }, + ) diff --git a/howl/web/templates/base.html b/howl/web/templates/base.html index b0dcedc..72f0a46 100644 --- a/howl/web/templates/base.html +++ b/howl/web/templates/base.html @@ -102,6 +102,14 @@ Sender Profiles +
  • + + + + + DB Users + +
  • @@ -124,5 +132,15 @@ + + + + + + diff --git a/howl/web/templates/dbuser_credentials_modal.html b/howl/web/templates/dbuser_credentials_modal.html new file mode 100644 index 0000000..2c4bedf --- /dev/null +++ b/howl/web/templates/dbuser_credentials_modal.html @@ -0,0 +1,45 @@ +
    +
    +

    User Created

    +
    +
    + +
    + + + + Save these credentials now. The password will never be shown again. +
    + +
    + +
    + {{ username }} + +
    +
    + +
    + +
    + {{ password }} + +
    +
    + +
    +

    Permission level: {{ permission_label }}

    +
    + +
    +
    +
    +
    diff --git a/howl/web/templates/dbuser_form.html b/howl/web/templates/dbuser_form.html new file mode 100644 index 0000000..4a307a5 --- /dev/null +++ b/howl/web/templates/dbuser_form.html @@ -0,0 +1,67 @@ +
    +
    +

    Create DB User

    +
    +
    + + {% if error %} +
    + {{ error }} +
    + {% endif %} + +
    +
    + +
    + howl_ + +
    + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    diff --git a/howl/web/templates/dbuser_row.html b/howl/web/templates/dbuser_row.html new file mode 100644 index 0000000..f3337bf --- /dev/null +++ b/howl/web/templates/dbuser_row.html @@ -0,0 +1,39 @@ + + {{ user.username }} + + {% if user.permission_level == 'read_only' %} + {{ permission_labels[user.permission_level] }} + {% elif user.permission_level == 'modify' %} + {{ permission_labels[user.permission_level] }} + {% else %} + {{ permission_labels[user.permission_level] }} + {% endif %} + + {{ user.description or "—" }} + {{ user.created_at.strftime('%Y-%m-%d') if user.created_at else "—" }} + + {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} + {% if drop_error is defined and drop_error %} + + PG error + + {% endif %} + + + {% if user.is_active %} + + {% endif %} + + diff --git a/howl/web/templates/dbusers.html b/howl/web/templates/dbusers.html new file mode 100644 index 0000000..eae7f51 --- /dev/null +++ b/howl/web/templates/dbusers.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}DB Users{% endblock %} + +{% block content %} +
    +
    +

    DB Users

    +

    PostgreSQL roles for direct database access

    +
    + +
    + +
    +
    + + + + + + + + + + + + + {% for user in users %} + {% include "dbuser_row.html" %} + {% else %} + + {% endfor %} + +
    UsernamePermissionDescriptionCreatedStatus
    No DB users yet.
    +
    +
    +{% endblock %} diff --git a/migrations/versions/0005_add_db_api_users.py b/migrations/versions/0005_add_db_api_users.py new file mode 100644 index 0000000..2a5c881 --- /dev/null +++ b/migrations/versions/0005_add_db_api_users.py @@ -0,0 +1,51 @@ +"""Add db_api_users table + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-04-02 +""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0005" +down_revision: Union[str, None] = "0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE TYPE db_permission_level AS ENUM ('read_only', 'modify', 'full')") + op.create_table( + "db_api_users", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("username", sa.Text, nullable=False, unique=True), + sa.Column( + "permission_level", + postgresql.ENUM("read_only", "modify", "full", name="db_permission_level", create_type=False), + nullable=False, + ), + sa.Column("description", sa.Text), + sa.Column("is_active", sa.Boolean, nullable=False, server_default="TRUE"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + ) + op.create_index( + "idx_db_api_users_username", + "db_api_users", + [sa.text("LOWER(username)")], + ) + + +def downgrade() -> None: + op.drop_table("db_api_users") + op.execute("DROP TYPE IF EXISTS db_permission_level")