Add REST API, DB user management, and API documentation

Adds a JSON REST API at /api/v1/ (Bearer token auth via API_KEYS env var)
exposing the full pipeline — email processing, log querying, sender profiles,
purge rules, whitelist, and analysis — for external consumers like OpenClaw.

Adds a /dbusers web UI for generating PostgreSQL roles with read_only,
modify, or full permission levels; credentials shown once and never stored.

Includes Alembic migration 0005 for the db_api_users tracking table and
API.md with full endpoint documentation and integration examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-04-02 14:47:39 -04:00
parent ecc6681432
commit 5af3fef37d
13 changed files with 1347 additions and 1 deletions

312
API.md Normal file
View file

@ -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 (331 chars, lowercase, letters/digits/underscores) — the role will be created as `howl_<username>`
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_<username>
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",
},
)
```

View file

@ -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

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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

503
howl/web/routes/api.py Normal file
View file

@ -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()

217
howl/web/routes/dbusers.py Normal file
View file

@ -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 331 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,
},
)

View file

@ -102,6 +102,14 @@
Sender Profiles
</a>
</li>
<li>
<a href="/dbusers" class="{% if active_page == 'dbusers' %}active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
DB Users
</a>
</li>
</ul>
</nav>
@ -124,5 +132,15 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- DB user modal -->
<dialog id="dbuser-modal" class="modal">
<div class="modal-box max-w-lg">
<div id="dbuser-modal-body">
<!-- Loaded via htmx -->
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</body>
</html>

View file

@ -0,0 +1,45 @@
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg text-success">User Created</h3>
<form method="dialog"><button class="btn btn-sm btn-ghost"></button></form>
</div>
<div class="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<span><strong>Save these credentials now.</strong> The password will never be shown again.</span>
</div>
<div class="mb-3">
<label class="label pb-1"><span class="label-text font-medium">Username</span></label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-base-200 rounded px-3 py-2 text-sm font-mono select-all">{{ username }}</code>
<button
class="btn btn-sm btn-ghost"
onclick="navigator.clipboard.writeText('{{ username }}');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)"
type="button"
>Copy</button>
</div>
</div>
<div class="mb-6">
<label class="label pb-1"><span class="label-text font-medium">Password</span></label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-base-200 rounded px-3 py-2 text-sm font-mono select-all break-all">{{ password }}</code>
<button
class="btn btn-sm btn-ghost"
onclick="navigator.clipboard.writeText('{{ password }}');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)"
type="button"
>Copy</button>
</div>
</div>
<div class="mb-4 text-sm text-base-content/60">
<p class="mb-1">Permission level: <span class="badge badge-sm">{{ permission_label }}</span></p>
</div>
<div class="flex justify-end">
<form method="dialog"><button class="btn btn-sm btn-primary">Done</button></form>
</div>
</div>

View file

@ -0,0 +1,67 @@
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg">Create DB User</h3>
<form method="dialog"><button class="btn btn-sm btn-ghost"></button></form>
</div>
{% if error %}
<div class="alert alert-error mb-4">
<span>{{ error }}</span>
</div>
{% endif %}
<form
hx-post="/dbusers"
hx-target="#dbuser-modal-body"
hx-swap="innerHTML"
>
<div class="form-control mb-4">
<label class="label"><span class="label-text">Username</span></label>
<div class="flex items-center gap-1">
<span class="text-base-content/50 font-mono text-sm">howl_</span>
<input
type="text"
name="username"
class="input input-bordered input-sm flex-1 font-mono"
placeholder="openclaw"
value="{{ prefill_username or '' }}"
pattern="[a-z][a-z0-9_]{2,30}"
title="331 lowercase letters, digits, or underscores, starting with a letter"
required
/>
</div>
<label class="label"><span class="label-text-alt text-base-content/40">Lowercase letters, digits, underscores. Final role: howl_username</span></label>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">Permission Level</span></label>
<select name="permission_level" class="select select-bordered select-sm" required>
{% for level in permission_levels %}
<option value="{{ level }}" {% if prefill_permission == level %}selected{% endif %}>
{{ permission_labels[level] }}
{% if level == 'read_only' %} — SELECT on all tables
{% elif level == 'modify' %} — SELECT + INSERT/UPDATE on core tables
{% else %} — SELECT + INSERT/UPDATE/DELETE on all tables
{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="form-control mb-6">
<label class="label"><span class="label-text">Description <span class="text-base-content/40">(optional)</span></span></label>
<input
type="text"
name="description"
class="input input-bordered input-sm"
placeholder="e.g. OpenClaw integration"
value="{{ prefill_description or '' }}"
/>
</div>
<div class="flex justify-end gap-2">
<form method="dialog"><button class="btn btn-sm btn-ghost">Cancel</button></form>
<button type="submit" class="btn btn-sm btn-primary">Create User</button>
</div>
</form>
</div>

View file

@ -0,0 +1,39 @@
<tr id="dbuser-row-{{ user.id }}">
<td class="font-mono text-sm">{{ user.username }}</td>
<td>
{% if user.permission_level == 'read_only' %}
<span class="badge badge-info badge-sm">{{ permission_labels[user.permission_level] }}</span>
{% elif user.permission_level == 'modify' %}
<span class="badge badge-warning badge-sm">{{ permission_labels[user.permission_level] }}</span>
{% else %}
<span class="badge badge-error badge-sm">{{ permission_labels[user.permission_level] }}</span>
{% endif %}
</td>
<td class="text-sm text-base-content/70">{{ user.description or "—" }}</td>
<td class="text-sm text-base-content/50">{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else "—" }}</td>
<td>
{% if user.is_active %}
<span class="badge badge-success badge-sm">Active</span>
{% else %}
<span class="badge badge-ghost badge-sm">Inactive</span>
{% endif %}
{% if drop_error is defined and drop_error %}
<span class="tooltip tooltip-left" data-tip="{{ drop_error }}">
<span class="badge badge-warning badge-sm ml-1">PG error</span>
</span>
{% endif %}
</td>
<td>
{% if user.is_active %}
<button
class="btn btn-ghost btn-xs text-error"
hx-delete="/dbusers/{{ user.id }}"
hx-target="#dbuser-row-{{ user.id }}"
hx-swap="outerHTML"
hx-confirm="Drop role '{{ user.username }}' and deactivate? This cannot be undone."
>
Revoke
</button>
{% endif %}
</td>
</tr>

View file

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}DB Users{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">DB Users</h1>
<p class="text-base-content/60 text-sm mt-1">PostgreSQL roles for direct database access</p>
</div>
<button
class="btn btn-primary btn-sm"
hx-get="/dbusers/form"
hx-target="#dbuser-modal-body"
hx-swap="innerHTML"
onclick="document.getElementById('dbuser-modal').showModal()"
>
+ Create User
</button>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-0">
<table class="table table-zebra">
<thead>
<tr>
<th>Username</th>
<th>Permission</th>
<th>Description</th>
<th>Created</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="dbusers-table-body"
hx-on:db-user-created="htmx.trigger(this, 'refresh')"
hx-get="/dbusers"
hx-trigger="dbUserCreated from:body"
hx-select="#dbusers-table-body"
hx-swap="outerHTML"
>
{% for user in users %}
{% include "dbuser_row.html" %}
{% else %}
<tr><td colspan="6" class="text-center text-base-content/40 py-8">No DB users yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View file

@ -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")