added backends, improved templating, rbac

This commit is contained in:
2026-06-10 14:40:22 +02:00
parent d9ca48addc
commit ce43f2658f
28 changed files with 1183 additions and 78 deletions

19
.gitignore vendored
View File

@@ -129,13 +129,13 @@ celerybeat.pid
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.env*
.venv*
env*/
venv*/
ENV*/
env*.bak/
venv*.bak/
# Spyder project settings
.spyderproject
@@ -176,3 +176,8 @@ cython_debug/
.vscode
.vscode/**
runtime
runtime/
server/*.db

View File

@@ -922,3 +922,28 @@ curl -X POST 'http://127.0.0.1:8000/api/v1/campaigns/<campaign-id>/versions/<ver
```
Strict validation/build/send endpoints are unchanged. The WebUI should use partial validation while editing, and only call strict validation/build when the user reaches Review/Send.
## Browser login / session auth
The backend now supports both automation API keys and browser session tokens.
Development login after `init_db --with-dev-data` or dev bootstrap:
```text
Tenant: default
Email: admin@example.local
Password: dev-admin
```
Login endpoint:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"tenant_slug":"default","email":"admin@example.local","password":"dev-admin"}'
```
Use the returned `access_token` as `Authorization: Bearer <token>`. Existing API keys still work via `X-API-Key`.
RBAC scaffolding now includes users, groups, roles, direct user-role assignments, group-role assignments and login sessions. The development user receives the `owner` role.

View File

@@ -0,0 +1,130 @@
"""auth sessions and RBAC assignments
Revision ID: 2c3d4e5f6a7b
Revises: 1f8d4c2a0b7e
Create Date: 2026-06-08 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2c3d4e5f6a7b"
down_revision = "1f8d4c2a0b7e"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(sa.Column("auth_provider", sa.String(length=50), nullable=False, server_default="local"))
batch_op.add_column(sa.Column("password_hash", sa.String(length=500), nullable=True))
batch_op.add_column(sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
op.create_table(
"user_group_memberships",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("group_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),
)
op.create_index(op.f("ix_user_group_memberships_tenant_id"), "user_group_memberships", ["tenant_id"])
op.create_index(op.f("ix_user_group_memberships_user_id"), "user_group_memberships", ["user_id"])
op.create_index(op.f("ix_user_group_memberships_group_id"), "user_group_memberships", ["group_id"])
op.create_table(
"user_role_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),
)
op.create_index(op.f("ix_user_role_assignments_tenant_id"), "user_role_assignments", ["tenant_id"])
op.create_index(op.f("ix_user_role_assignments_user_id"), "user_role_assignments", ["user_id"])
op.create_index(op.f("ix_user_role_assignments_role_id"), "user_role_assignments", ["role_id"])
op.create_table(
"group_role_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("group_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),
)
op.create_index(op.f("ix_group_role_assignments_tenant_id"), "group_role_assignments", ["tenant_id"])
op.create_index(op.f("ix_group_role_assignments_group_id"), "group_role_assignments", ["group_id"])
op.create_index(op.f("ix_group_role_assignments_role_id"), "group_role_assignments", ["role_id"])
op.create_table(
"auth_sessions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=128), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("ip_address", sa.String(length=100), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index(op.f("ix_auth_sessions_tenant_id"), "auth_sessions", ["tenant_id"])
op.create_index(op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"])
op.create_index(op.f("ix_auth_sessions_token_hash"), "auth_sessions", ["token_hash"])
op.create_index(op.f("ix_auth_sessions_expires_at"), "auth_sessions", ["expires_at"])
op.create_index(op.f("ix_auth_sessions_revoked_at"), "auth_sessions", ["revoked_at"])
def downgrade() -> None:
op.drop_index(op.f("ix_auth_sessions_revoked_at"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_expires_at"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_token_hash"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_tenant_id"), table_name="auth_sessions")
op.drop_table("auth_sessions")
op.drop_index(op.f("ix_group_role_assignments_role_id"), table_name="group_role_assignments")
op.drop_index(op.f("ix_group_role_assignments_group_id"), table_name="group_role_assignments")
op.drop_index(op.f("ix_group_role_assignments_tenant_id"), table_name="group_role_assignments")
op.drop_table("group_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_role_id"), table_name="user_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_user_id"), table_name="user_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_tenant_id"), table_name="user_role_assignments")
op.drop_table("user_role_assignments")
op.drop_index(op.f("ix_user_group_memberships_group_id"), table_name="user_group_memberships")
op.drop_index(op.f("ix_user_group_memberships_user_id"), table_name="user_group_memberships")
op.drop_index(op.f("ix_user_group_memberships_tenant_id"), table_name="user_group_memberships")
op.drop_table("user_group_memberships")
with op.batch_alter_table("users") as batch_op:
batch_op.drop_column("last_login_at")
batch_op.drop_column("password_hash")
batch_op.drop_column("auth_provider")

View File

@@ -1,12 +1,16 @@
from fastapi import APIRouter
from .admin import router as admin_router
from .auth import router as auth_router
from .campaigns import router as campaigns_router
from .audit import router as audit_router
from .system import router as system_router
from .mail import router as mail_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(campaigns_router)
router.include_router(admin_router)
router.include_router(audit_router)
router.include_router(system_router)
router.include_router(mail_router)

128
server/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.api.v1.schemas import GroupInfo, LoginRequest, LoginResponse, MeResponse, RoleInfo, TenantInfo, TenantMembershipInfo, UserInfo
from app.auth.dependencies import ApiPrincipal, get_api_principal
from app.db.models import Tenant, User
from app.db.session import get_session
from app.security.passwords import verify_password
from app.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes, create_auth_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"])
def _tenant_info(tenant: Tenant) -> TenantInfo:
return TenantInfo(id=tenant.id, slug=tenant.slug, name=tenant.name)
def _user_info(user: User) -> UserInfo:
return UserInfo(id=user.id, email=user.email, display_name=user.display_name, is_tenant_admin=user.is_tenant_admin)
def _roles_info(roles) -> list[RoleInfo]:
return [RoleInfo(id=r.id, slug=r.slug, name=r.name, permissions=r.permissions or []) for r in roles]
def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=g.id, slug=g.slug, name=g.name) for g in groups]
def _tenant_memberships_for_email(session: Session, email: str) -> list[TenantMembershipInfo]:
"""Return tenants that currently contain an active user with this email.
The current data model still stores users inside one tenant. Until a dedicated
cross-tenant identity table exists, matching by email is the lightweight bridge
that lets the frontend render tenant context and later expose switching only
when more than one membership exists.
"""
rows = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.email == email, User.is_active.is_(True), Tenant.is_active.is_(True))
.order_by(Tenant.name.asc())
.all()
)
memberships: list[TenantMembershipInfo] = []
for user, tenant in rows:
roles = collect_user_roles(session, user)
memberships.append(
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
roles=[role.slug for role in roles],
is_active=True,
)
)
return memberships
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[User, Tenant]:
query = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.email == payload.email, User.is_active.is_(True), Tenant.is_active.is_(True))
)
if payload.tenant_slug:
query = query.filter(Tenant.slug == payload.tenant_slug)
rows = query.order_by(Tenant.name.asc()).all()
for user, tenant in rows:
if user.password_hash and verify_password(payload.password, user.password_hash):
return user, tenant
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, session: Session = Depends(get_session)):
user, tenant = _resolve_login_user(session, payload)
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else None
created = create_auth_session(session, user=user, user_agent=user_agent, ip_address=ip_address)
roles = collect_user_roles(session, user)
groups = collect_user_groups(session, user)
scopes = collect_user_scopes(session, user)
tenants = _tenant_memberships_for_email(session, user.email)
session.commit()
active_tenant = _tenant_info(tenant)
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
user=_user_info(user),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=tenants,
scopes=scopes,
roles=_roles_info(roles),
groups=_groups_info(groups),
)
@router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id)
roles = collect_user_roles(session, principal.user)
groups = collect_user_groups(session, principal.user)
active_tenant = _tenant_info(tenant)
return MeResponse(
user=_user_info(principal.user),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=_tenant_memberships_for_email(session, principal.user.email),
scopes=principal.scopes,
roles=_roles_info(roles),
groups=_groups_info(groups),
)
@router.post("/logout")
def logout(request: Request, session: Session = Depends(get_session)):
authorization = request.headers.get("authorization") or ""
if authorization.lower().startswith("bearer "):
revoke_auth_session(session, authorization[7:].strip())
session.commit()
return {"ok": True}

116
server/app/api/v1/mail.py Normal file
View File

@@ -0,0 +1,116 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.api.v1.schemas import (
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestRequest,
MailSmtpTestRequest,
)
from app.auth.dependencies import ApiPrincipal, require_scope
from app.mailer.sending.imap import list_imap_folders, test_imap_login
from app.mailer.sending.smtp import test_smtp_login
router = APIRouter(prefix="/mail", tags=["mail"])
def _safe_error_message(exc: Exception) -> str:
text = str(exc).strip()
return text or exc.__class__.__name__
@router.post("/test-smtp", response_model=MailConnectionTestResponse)
def test_smtp_settings(
payload: MailSmtpTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Test SMTP connectivity/login without sending any message."""
del principal
try:
result = test_smtp_login(smtp_config=payload)
return MailConnectionTestResponse(
ok=True,
protocol="smtp",
host=result.host,
port=result.port,
security=result.security,
message="SMTP connection successful.",
details={"authenticated": result.authenticated},
)
except Exception as exc:
return MailConnectionTestResponse(
ok=False,
protocol="smtp",
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
details={"error_type": exc.__class__.__name__},
)
@router.post("/test-imap", response_model=MailConnectionTestResponse)
def test_imap_settings(
payload: MailImapTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Test IMAP connectivity/login without selecting or appending messages."""
del principal
try:
result = test_imap_login(imap_config=payload)
return MailConnectionTestResponse(
ok=True,
protocol="imap",
host=result.host,
port=result.port,
security=result.security,
message="IMAP connection successful.",
details={"authenticated": result.authenticated},
)
except Exception as exc:
return MailConnectionTestResponse(
ok=False,
protocol="imap",
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
details={"error_type": exc.__class__.__name__},
)
@router.post("/list-imap-folders", response_model=MailImapFolderListResponse)
def list_imap_folder_settings(
payload: MailImapTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""List visible IMAP folders and return the best Sent-folder guess."""
del principal
try:
result = list_imap_folders(imap_config=payload)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
return MailImapFolderListResponse(
ok=True,
host=result.host,
port=result.port,
security=result.security,
message=f"Found {len(folders)} IMAP folder(s).",
folders=folders,
detected_sent_folder=result.detected_sent_folder,
)
except Exception as exc:
return MailImapFolderListResponse(
ok=False,
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
folders=[],
detected_sent_folder=None,
details={"error_type": exc.__class__.__name__},
)

View File

@@ -1,10 +1,12 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from app.mailer.campaign.models import ImapConfig, SmtpConfig
class CampaignCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -126,6 +128,43 @@ class BuildCampaignRequest(BaseModel):
write_eml: bool = True
class MailSmtpTestRequest(SmtpConfig):
"""SMTP settings supplied directly from the WebUI mail settings form."""
class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form."""
enabled: bool = True
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
host: str | None = None
port: int | None = None
security: str | None = None
message: str
details: dict[str, Any] = Field(default_factory=dict)
class MailImapFolderResponse(BaseModel):
name: str
flags: list[str] = Field(default_factory=list)
class MailImapFolderListResponse(BaseModel):
ok: bool
protocol: Literal["imap"] = "imap"
host: str | None = None
port: int | None = None
security: str | None = None
message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class ApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -200,3 +239,70 @@ class AuditLogItemResponse(BaseModel):
class AuditLogListResponse(BaseModel):
items: list[AuditLogItemResponse]
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
password: str
# Kept optional for backwards compatibility and future tenant-switch login flows.
# The WebUI no longer sends it. If omitted, the backend resolves the user by email.
tenant_slug: str | None = None
class TenantInfo(BaseModel):
id: str
slug: str
name: str
class TenantMembershipInfo(TenantInfo):
roles: list[str] = Field(default_factory=list)
is_active: bool = True
class UserInfo(BaseModel):
id: str
email: str
display_name: str | None = None
is_tenant_admin: bool = False
class RoleInfo(BaseModel):
id: str
slug: str
name: str
permissions: list[str] = Field(default_factory=list)
class GroupInfo(BaseModel):
id: str
slug: str
name: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)
class MeResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)

View File

@@ -82,7 +82,7 @@ def audit_from_principal(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
api_key_id=principal.api_key.id,
api_key_id=principal.api_key.id if principal.api_key else None,
action=action,
object_type=object_type,
object_id=object_id,

View File

@@ -5,24 +5,27 @@ from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.db.models import ApiKey, User
from app.db.models import ApiKey, AuthSession, User
from app.db.session import get_session
from app.security.api_keys import authenticate_api_key, has_scope
from app.security.api_keys import authenticate_api_key, has_scope as api_key_has_scope
from app.security.sessions import authenticate_session_token, collect_user_scopes
@dataclass(slots=True)
class ApiPrincipal:
api_key: ApiKey
user: User
tenant_id: str
scopes: list[str]
api_key: ApiKey | None = None
auth_session: AuthSession | None = None
def _extract_api_key(authorization: str | None, x_api_key: str | None) -> str | None:
def _extract_token(authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
if x_api_key:
return x_api_key.strip()
return x_api_key.strip(), "api_key"
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip()
return None
return authorization[7:].strip(), "bearer"
return None, "none"
def get_api_principal(
@@ -30,22 +33,41 @@ def get_api_principal(
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal:
secret = _extract_api_key(authorization, x_api_key)
if not secret:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key")
api_key = authenticate_api_key(session, secret)
if not api_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
token, source = _extract_token(authorization, x_api_key)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
# API keys remain supported for CLI/automation. Browser login uses session tokens.
api_key = authenticate_api_key(session, token)
if api_key:
user = session.get(User, api_key.user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
session.commit()
return ApiPrincipal(api_key=api_key, user=user, tenant_id=api_key.tenant_id)
return ApiPrincipal(api_key=api_key, user=user, tenant_id=api_key.tenant_id, scopes=api_key.scopes or [])
auth_session = authenticate_session_token(session, token)
if auth_session:
user = session.get(User, auth_session.user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
scopes = collect_user_scopes(session, user)
session.commit()
return ApiPrincipal(auth_session=auth_session, user=user, tenant_id=user.tenant_id, scopes=scopes)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def has_scope(principal: ApiPrincipal, required_scope: str) -> bool:
scopes = set(principal.scopes or [])
if principal.api_key:
return api_key_has_scope(principal.api_key, required_scope)
return "*" in scopes or required_scope in scopes
def require_scope(required_scope: str):
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
if not has_scope(principal.api_key, required_scope):
if not has_scope(principal, required_scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}")
return principal

View File

@@ -5,9 +5,10 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.db.base import Base
from app.db.models import Role, Tenant, User
from app.db.models import Role, Tenant, User, UserRoleAssignment
from app.db.session import engine
from app.security.api_keys import CreatedApiKey, create_api_key
from app.security.passwords import hash_password
DEFAULT_SCOPES = [
"campaign:read",
@@ -62,6 +63,7 @@ def bootstrap_dev_data(
api_key_secret: str | None = None,
tenant_slug: str = "default",
user_email: str = "admin@example.local",
user_password: str = "dev-admin",
) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None:
@@ -76,9 +78,23 @@ def bootstrap_dev_data(
user = session.query(User).filter(User.tenant_id == tenant.id, User.email == user_email).one_or_none()
if user is None:
user = User(tenant_id=tenant.id, email=user_email, display_name="Development Admin", is_tenant_admin=True)
user = User(tenant_id=tenant.id, email=user_email, display_name="Development Admin", is_tenant_admin=True, password_hash=hash_password(user_password))
session.add(user)
session.flush()
elif not user.password_hash:
user.password_hash = hash_password(user_password)
session.add(user)
# Development owner role assignment for RBAC/session login.
owner_role = session.query(Role).filter(Role.tenant_id == tenant.id, Role.slug == "owner").one_or_none()
if owner_role is not None:
existing_assignment = session.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant.id,
UserRoleAssignment.user_id == user.id,
UserRoleAssignment.role_id == owner_role.id,
).one_or_none()
if existing_assignment is None:
session.add(UserRoleAssignment(tenant_id=tenant.id, user_id=user.id, role_id=owner_role.id))
created_api_key = None
if api_key_secret:

View File

@@ -117,9 +117,13 @@ class User(Base, TimestampMixin):
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_tenant_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(500))
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tenant: Mapped[Tenant] = relationship(back_populates="users")
api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan")
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
class Group(Base, TimestampMixin):
@@ -143,6 +147,38 @@ class Role(Base, TimestampMixin):
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
class UserGroupMembership(Base, TimestampMixin):
__tablename__ = "user_group_memberships"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
class UserRoleAssignment(Base, TimestampMixin):
__tablename__ = "user_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
class GroupRoleAssignment(Base, TimestampMixin):
__tablename__ = "group_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
class ApiKey(Base, TimestampMixin):
__tablename__ = "api_keys"
@@ -160,6 +196,24 @@ class ApiKey(Base, TimestampMixin):
user: Mapped[User] = relationship(back_populates="api_keys")
class AuthSession(Base, TimestampMixin):
__tablename__ = "auth_sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
user_agent: Mapped[str | None] = mapped_column(String(500))
ip_address: Mapped[str | None] = mapped_column(String(100))
user: Mapped[User] = relationship(back_populates="auth_sessions")
class Campaign(Base, TimestampMixin):
__tablename__ = "campaigns"
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import fnmatch
import re
from enum import StrEnum
from pathlib import Path
from typing import Any, Iterable
@@ -8,6 +9,7 @@ from typing import Any, Iterable
from pydantic import BaseModel, ConfigDict, Field
from app.mailer.campaign.entries import load_campaign_entries
from app.mailer.campaign.field_values import effective_entry_field_values
from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig
@@ -126,11 +128,39 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
return (campaign_path.parent / path).resolve()
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def _render_template(template: str, values: dict[str, Any]) -> str:
rendered = template
for key, value in values.items():
rendered = rendered.replace("${" + key + "}", "" if value is None else str(value))
return rendered
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0)
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _recipient_values(entry: EntryConfig) -> dict[str, str]:
@@ -153,7 +183,7 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
values: dict[str, Any] = {}
for key, value in config.global_values.items():
values[f"global::{key}"] = value
for key, value in entry.fields.items():
for key, value in effective_entry_field_values(config, entry).items():
values[f"local::{key}"] = value
if entry.id:
values["local::id"] = entry.id

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from .models import CampaignConfig, EntryConfig, FieldDefinition
def field_definitions_by_name(config: CampaignConfig) -> dict[str, FieldDefinition]:
"""Return campaign field definitions keyed by field id/name."""
return {field.name: field for field in config.fields}
def field_can_override(config: CampaignConfig, field_name: str) -> bool:
"""Return whether a recipient/entry value may override the global value.
Unknown fields remain overridable for backwards compatibility with older
campaigns and ad-hoc external mappings. Semantic validation reports unknown
field usage separately when a field list is configured.
"""
field = field_definitions_by_name(config).get(field_name)
if field is None:
return True
return field.can_override
def ignored_entry_field_overrides(config: CampaignConfig, entry: EntryConfig) -> list[str]:
"""Return recipient field keys that are ignored by the override policy."""
return sorted(name for name in entry.fields if not field_can_override(config, name))
def effective_entry_field_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
"""Return the local/effective field value map for one message entry.
Global values act as defaults for local template placeholders. Recipient
values replace those defaults only when the corresponding field allows
overrides. Fields that are unknown to the campaign definition keep the old
permissive behavior and remain usable as local values.
"""
values: dict[str, Any] = dict(config.global_values)
for key, value in entry.fields.items():
if field_can_override(config, key) and entry_field_has_override_value(value):
values[key] = value
return values
def entry_field_has_override_value(value: Any) -> bool:
"""Return whether an entry field should override a global default.
Empty recipient values are treated as "not set" so global_values remain the
effective local defaults. Numeric zero and boolean false are valid explicit
overrides.
"""
if value is None:
return False
if isinstance(value, str):
return value.strip() != ""
return True

View File

@@ -91,6 +91,7 @@ class FieldDefinition(StrictModel):
type: FieldType = FieldType.STRING
label: str | None = None
required: bool = False
can_override: bool = True
class SmtpConfig(StrictModel):

View File

@@ -7,7 +7,8 @@ from typing import Iterable
from pydantic import BaseModel, ConfigDict, Field
from .models import CampaignConfig, SourceType
from .field_values import ignored_entry_field_overrides
from .models import CampaignConfig, EntryConfig, SourceType
class Severity(StrEnum):
@@ -61,6 +62,12 @@ def _resolve(campaign_file: Path, raw_path: str) -> Path:
return (campaign_file.parent / path).resolve()
def _mapping_target_field_name(target: str) -> str | None:
if target.startswith("fields."):
return target.split(".", 1)[1]
return None
def _mapping_target_known(target: str, field_names: set[str]) -> bool:
direct_targets = {
"id",
@@ -129,6 +136,18 @@ def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, s
return paths
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
return [
_issue(
Severity.WARNING,
"field_override_not_allowed",
f"recipient value for field {field_name!r} will be ignored because the field does not allow overrides",
f"{path_prefix}/fields/{field_name}",
)
for field_name in ignored_entry_field_overrides(config, entry)
]
def validate_campaign_config(
config: CampaignConfig,
*,
@@ -139,7 +158,8 @@ def validate_campaign_config(
issues: list[SemanticIssue] = []
field_names = config.field_names
declared_names = {field.name for field in config.fields}
field_definitions = {field.name: field for field in config.fields}
declared_names = set(field_definitions)
for key in config.global_values:
if declared_names and key not in declared_names:
@@ -187,10 +207,13 @@ def validate_campaign_config(
))
if config.entries.is_inline:
entries_count = len(config.entries.inline or [])
inline_entries = config.entries.inline or []
entries_count = len(inline_entries)
entries_mode = "inline"
if entries_count == 0:
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
for index, entry in enumerate(inline_entries):
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
else:
entries_count = None
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
@@ -205,6 +228,16 @@ def validate_campaign_config(
f"mapping target {target!r} is not recognized by the current campaign model",
f"/entries/mapping/{target}",
))
field_name = _mapping_target_field_name(target)
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
issues.append(_issue(
Severity.WARNING,
"mapping_target_not_overridable",
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
f"/entries/mapping/{target}",
))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source:
source_path = _resolve(campaign_path, config.entries.source.path)
if not source_path.exists():

View File

@@ -18,6 +18,7 @@ class FieldType(StrEnum):
class FieldDescription:
name: str
type: FieldType = FieldType.STRING
can_override: bool = True
@dataclass(slots=True)

View File

@@ -17,6 +17,7 @@ from app.mailer.attachments.resolver import (
resolve_entry_attachments,
)
from app.mailer.campaign.entries import load_campaign_entries
from app.mailer.campaign.field_values import effective_entry_field_values, ignored_entry_field_overrides
from app.mailer.campaign.models import (
Behavior,
BuildStatus,
@@ -38,7 +39,26 @@ from .models import (
MessageValidationStatus,
)
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
@dataclass(slots=True)
@@ -70,20 +90,25 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group(1)
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _FIELD_PATTERN.sub(replace, template)
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return set(_FIELD_PATTERN.findall(text))
return {
_normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}
def _recipient_values(entry: EntryConfig) -> dict[str, str]:
@@ -106,7 +131,7 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
values: dict[str, Any] = {}
for key, value in config.global_values.items():
values[f"global::{key}"] = value
for key, value in entry.fields.items():
for key, value in effective_entry_field_values(config, entry).items():
values[f"local::{key}"] = value
if entry.id:
values["local::id"] = entry.id
@@ -390,6 +415,20 @@ def build_entry_message(
issues = _message_issues_from_attachment_resolution(resolution)
validation_status = _validation_status_from_attachment_status(resolution.status)
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
if ignored_field_overrides:
issues.append(
MessageIssue(
severity="warning",
code="field_override_not_allowed",
message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides),
behavior="warn",
source="fields",
)
)
if validation_status == MessageValidationStatus.READY:
validation_status = MessageValidationStatus.WARNING
if not entry.active:
draft = MessageDraft(
entry_index=entry_index,

View File

@@ -70,6 +70,11 @@
"required": {
"type": "boolean",
"default": false
},
"can_override": {
"type": "boolean",
"default": true,
"description": "Whether recipient/entry field values may override the global value for this field."
}
},
"additionalProperties": false

View File

@@ -26,6 +26,29 @@ class ImapAppendError(RuntimeError):
self.temporary = temporary
@dataclass(frozen=True, slots=True)
class ImapLoginTestResult:
host: str
port: int
security: str
authenticated: bool
@dataclass(frozen=True, slots=True)
class ImapMailboxInfo:
name: str
flags: list[str]
@dataclass(frozen=True, slots=True)
class ImapFolderListResult:
host: str
port: int
security: str
folders: list[ImapMailboxInfo]
detected_sent_folder: str | None = None
@dataclass(frozen=True, slots=True)
class ImapAppendResult:
host: str
@@ -83,43 +106,57 @@ def _decode_item(item: bytes | str | None) -> str:
return item
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
"""Best-effort parser for IMAP LIST response lines.
def _unquote_imap_token(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
value = value[1:-1]
value = value.replace('\\"', '"').replace('\\\\', '\\')
return value
Example lines:
(\\HasNoChildren \\Sent) "/" "Sent"
(\\HasNoChildren) "/" "Sent Items"
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
r"""Best-effort parser for IMAP LIST response lines.
RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox
name. Some servers quote both delimiter and mailbox::
(\HasNoChildren \Sent) "/" "Sent"
Others quote only the delimiter and leave the mailbox as an atom::
(\HasNoChildren \Sent) "/" Sent
The parser must therefore parse the delimiter token separately instead of
blindly taking the last quoted value.
"""
line = _decode_item(list_response_line).strip()
flags_match = re.match(r"^\((?P<flags>[^)]*)\)\s+", line)
flags = set()
if flags_match:
flags = {part.lower() for part in flags_match.group("flags").split()}
match = re.match(
r'^\((?P<flags>[^)]*)\)\s+'
r'(?P<delimiter>"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+'
r'(?P<mailbox>.+?)\s*$',
line,
re.IGNORECASE,
)
if match:
flags = {part.lower() for part in match.group("flags").split()}
mailbox = _unquote_imap_token(match.group("mailbox"))
if mailbox:
return mailbox, flags
return None
quoted = re.findall(r'"((?:[^"\\]|\\.)*)"', line)
if quoted:
# Usually: delimiter, mailbox. Take the last quoted token.
return quoted[-1].replace(r'\"', '"'), flags
# Fallback for unquoted final atom.
parts = line.split()
if parts:
return parts[-1], flags
# Fallback for non-standard server lines: prefer the final token.
parts = line.split(maxsplit=2)
if len(parts) >= 3:
flags_text = parts[0].strip("()")
flags = {part.lower() for part in flags_text.split()}
mailbox = _unquote_imap_token(parts[2])
if mailbox:
return mailbox, flags
return None
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
typ, data = client.list()
if typ != "OK" or not data:
return None
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
for name, flags in parsed:
if "\\sent" in flags or "\\sentmail" in flags:
return name
@@ -140,6 +177,20 @@ def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
return None
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
typ, data = client.list()
if typ != "OK" or not data:
return None
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
return _detect_sent_folder(parsed)
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
if requested_folder and requested_folder != "auto":
return requested_folder
@@ -151,6 +202,63 @@ def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None,
raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder")
def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
"""Open an IMAP connection and authenticate if credentials are configured.
This is a side-effect-free connection test for the WebUI. It does not select
a mailbox and does not append any message.
"""
host, port = _require_imap_config(imap_config)
client = _open_imap(imap_config)
try:
return ImapLoginTestResult(
host=host,
port=port,
security=imap_config.security.value,
authenticated=bool(imap_config.username and imap_config.password),
)
finally:
try:
client.logout()
except Exception:
pass
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config)
client = _open_imap(imap_config)
try:
typ, data = client.list()
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
)
finally:
try:
client.logout()
except Exception:
pass
def append_message_to_sent(
message_bytes: bytes,
*,

View File

@@ -18,6 +18,14 @@ class SmtpSendError(RuntimeError):
"""Raised when an SMTP send attempt fails."""
@dataclass(frozen=True, slots=True)
class SmtpLoginTestResult:
host: str
port: int
security: str
authenticated: bool
@dataclass(frozen=True, slots=True)
class SmtpSendResult:
host: str
@@ -80,6 +88,34 @@ def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[in
return normalized
def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
"""Open an SMTP connection and authenticate if credentials are configured.
This is intentionally side-effect free: it does not send a message and it
never receives envelope or recipient data. It is used by the WebUI to check
whether the configured transport can be reached before a campaign is built
or queued.
"""
host, port = _require_smtp_config(smtp_config)
smtp = _open_smtp(smtp_config)
try:
return SmtpLoginTestResult(
host=host,
port=port,
security=smtp_config.security.value,
authenticated=bool(smtp_config.username and smtp_config.password),
)
finally:
try:
smtp.quit()
except Exception:
try:
smtp.close()
except Exception:
pass
def prepare_test_message(
message: EmailMessage,
*,

View File

@@ -14,7 +14,7 @@ async def lifespan(app: FastAPI):
if settings.app_env == "dev" and settings.dev_bootstrap_enabled:
create_all_tables()
with SessionLocal() as session:
bootstrap_dev_data(session, api_key_secret=settings.dev_bootstrap_api_key)
bootstrap_dev_data(session, api_key_secret=settings.dev_bootstrap_api_key, user_password=settings.dev_bootstrap_password)
yield
@@ -38,7 +38,7 @@ def health():
return {
"status": "ok",
"env": settings.app_env,
"api": {"version": "v1", "auth": "api-key"},
"api": {"version": "v1", "auth": "api-key-or-session"},
"storage": {
"endpoint": settings.s3_endpoint_url,
"bucket": settings.s3_bucket,

View File

@@ -4,11 +4,12 @@ import hashlib
import hmac
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy.orm import Session
from app.db.models import ApiKey, User
from app.security.time import ensure_aware_utc, utc_now
API_KEY_PREFIX_LENGTH = 12
API_KEY_RANDOM_BYTES = 32
@@ -64,9 +65,10 @@ def create_api_key(
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
prefix = api_key_prefix(secret)
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
now = datetime.now(timezone.utc)
now = utc_now()
for candidate in candidates:
if candidate.expires_at and candidate.expires_at < now:
expires_at = ensure_aware_utc(candidate.expires_at)
if expires_at and expires_at < now:
continue
if verify_api_key(secret, candidate.key_hash):
candidate.last_used_at = now

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
_ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
salt = os.urandom(_SALT_BYTES)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return "$".join([
_ALGORITHM,
str(iterations),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
])
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded:
return False
try:
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
if algorithm != _ALGORITHM:
return False
iterations = int(iterations_text)
salt = base64.b64decode(salt_b64.encode("ascii"))
expected = base64.b64decode(digest_b64.encode("ascii"))
except Exception:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected)

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy.orm import Session
from app.db.models import AuthSession, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
from app.security.time import ensure_aware_utc, utc_now
SESSION_RANDOM_BYTES = 32
DEFAULT_SESSION_HOURS = 12
@dataclass(slots=True)
class CreatedSession:
model: AuthSession
token: str
def generate_session_token() -> str:
return "ms_" + secrets.token_urlsafe(SESSION_RANDOM_BYTES)
def hash_session_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def verify_session_token(token: str, expected_hash: str) -> bool:
return hmac.compare_digest(hash_session_token(token), expected_hash)
def create_auth_session(
session: Session,
*,
user: User,
hours: int = DEFAULT_SESSION_HOURS,
user_agent: str | None = None,
ip_address: str | None = None,
) -> CreatedSession:
token = generate_session_token()
now = utc_now()
model = AuthSession(
tenant_id=user.tenant_id,
user_id=user.id,
token_hash=hash_session_token(token),
expires_at=now + timedelta(hours=hours),
last_seen_at=now,
user_agent=user_agent,
ip_address=ip_address,
)
user.last_login_at = now
session.add(model)
session.add(user)
session.flush()
return CreatedSession(model=model, token=token)
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
token_hash = hash_session_token(token)
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
if not model:
return None
now = utc_now()
expires_at = ensure_aware_utc(model.expires_at)
if expires_at is None or expires_at < now:
return None
model.last_seen_at = now
session.add(model)
return model
def revoke_auth_session(session: Session, token: str) -> bool:
model = authenticate_session_token(session, token)
if not model:
return False
model.revoked_at = utc_now()
session.add(model)
return True
def collect_user_roles(session: Session, user: User) -> list[Role]:
roles_by_id: dict[str, Role] = {}
direct = (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(UserRoleAssignment.user_id == user.id, UserRoleAssignment.tenant_id == user.tenant_id)
.all()
)
for role in direct:
roles_by_id[role.id] = role
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.filter(UserGroupMembership.user_id == user.id, UserGroupMembership.tenant_id == user.tenant_id)
.all()
)
for role in group_roles:
roles_by_id[role.id] = role
return list(roles_by_id.values())
def collect_user_groups(session: Session, user: User) -> list[Group]:
return (
session.query(Group)
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
.filter(UserGroupMembership.user_id == user.id, UserGroupMembership.tenant_id == user.tenant_id)
.all()
)
def collect_user_scopes(session: Session, user: User) -> list[str]:
if user.is_tenant_admin:
return ["*"]
scopes: set[str] = set()
for role in collect_user_roles(session, user):
scopes.update(role.permissions or [])
return sorted(scopes)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: datetime | None) -> datetime | None:
"""Return a timezone-aware UTC datetime.
SQLite and some DB drivers may return naive datetimes even when SQLAlchemy
columns are declared as DateTime(timezone=True). Treat naive values as UTC
so comparisons with aware `datetime.now(timezone.utc)` do not crash.
"""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)

View File

@@ -25,6 +25,7 @@ class Settings(BaseSettings):
# Development bootstrap only. Do not use this in production.
dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY")
dev_bootstrap_enabled: bool = Field(default=True, alias="DEV_BOOTSTRAP_ENABLED")
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
# Comma-separated list. Use * only for local development.
cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS")

Binary file not shown.