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:
parent
ecc6681432
commit
5af3fef37d
13 changed files with 1347 additions and 1 deletions
312
API.md
Normal file
312
API.md
Normal 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 (3–31 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",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
503
howl/web/routes/api.py
Normal 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
217
howl/web/routes/dbusers.py
Normal 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 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,
|
||||
},
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
45
howl/web/templates/dbuser_credentials_modal.html
Normal file
45
howl/web/templates/dbuser_credentials_modal.html
Normal 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>
|
||||
67
howl/web/templates/dbuser_form.html
Normal file
67
howl/web/templates/dbuser_form.html
Normal 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="3–31 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>
|
||||
39
howl/web/templates/dbuser_row.html
Normal file
39
howl/web/templates/dbuser_row.html
Normal 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>
|
||||
50
howl/web/templates/dbusers.html
Normal file
50
howl/web/templates/dbusers.html
Normal 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 %}
|
||||
51
migrations/versions/0005_add_db_api_users.py
Normal file
51
migrations/versions/0005_add_db_api_users.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue