Add pending account provisioning for demoing to not-yet-existing accounts

An admin can now queue up an email address in advance (POST/GET/DELETE
/system/pending-provisioning) with a source user and an optional admin
flag. The moment that email actually verifies a real account - not raw
registration, which proves nothing about ownership - it gets a deep copy
of the source user's per-user API keys and every company they own
(company profile, aliases, competitors, monitor config, sources, source
documents, monitoring runs, reports, snapshots, detected changes, and
enrichment - not just the company row), plus an email notification
destination for its own address linked to the copied companies.

The clone logic (_copy_row/_clone_company in provisioning_service.py) is
generic over every table it touches via column introspection, so it
doesn't need hand-maintained field lists per model.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-06 18:47:29 -04:00
co-authored by Claude Sonnet 5
parent 18305b545c
commit 56b4a5404e
9 changed files with 904 additions and 3 deletions
+40 -2
View File
@@ -7,11 +7,12 @@ ready to serve traffic".
from __future__ import annotations
import uuid
from typing import Literal
import httpx
import redis.asyncio as redis_asyncio
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,8 +24,9 @@ from app.core.security import get_client_ip, is_localhost
from app.db.session import get_db
from app.models.enums import SystemSecretKey
from app.models.user import User
from app.schemas.provisioning import PendingProvisioningCreate, PendingProvisioningResponse
from app.schemas.system_secret import SetSystemSecretRequest, SystemSecretStatus
from app.services import system_secret_service
from app.services import provisioning_service, system_secret_service
from app.services.enrichment_service import estimate_max_credits_per_company
logger = get_logger(__name__)
@@ -194,3 +196,39 @@ async def system_logs(
"""Most-recent-first view into the application's live log stream (capped
at the last 500 entries app-wide, see `core/logging.py`)."""
return [LogEntryResponse(**entry) for entry in await get_recent_logs(settings, limit=100)]
@router.get("/system/pending-provisioning", response_model=list[PendingProvisioningResponse])
async def list_pending_provisioning(
db: AsyncSession = Depends(get_db), _admin: User = Depends(require_admin)
) -> list[PendingProvisioningResponse]:
"""Queued not-yet-existing accounts - see app.services.provisioning_service."""
return [
PendingProvisioningResponse.model_validate(p, from_attributes=True)
for p in await provisioning_service.list_pending(db)
]
@router.post(
"/system/pending-provisioning",
response_model=PendingProvisioningResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_pending_provisioning(
payload: PendingProvisioningCreate,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> PendingProvisioningResponse:
record = await provisioning_service.create_pending(
db, payload.email, payload.source_user_id, payload.make_admin
)
return PendingProvisioningResponse.model_validate(record, from_attributes=True)
@router.delete("/system/pending-provisioning/{pending_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_pending_provisioning(
pending_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> None:
await provisioning_service.delete_pending(db, pending_id)
+1
View File
@@ -22,6 +22,7 @@ from app.models.notification_destination import ( # noqa: F401
NotificationDestinationCompany,
)
from app.models.password_history import PasswordHistoryEntry # noqa: F401
from app.models.pending_provisioning import PendingProvisioning # noqa: F401
from app.models.refresh_token import RefreshToken # noqa: F401
from app.models.report import Report # noqa: F401
from app.models.snapshot import Snapshot # noqa: F401
@@ -0,0 +1,31 @@
"""Lets an admin pre-configure what a not-yet-existing account should get
the moment someone verifies their email with a matching address - copies
of another user's per-user API keys and companies (with their full data:
sources, enrichment, reports, snapshots, etc.), an email notification
destination for the new account's own address, and optionally an admin
promotion. Built for demoing the app to people who don't have accounts
yet without hand-entering everything for them after the fact.
Applied at email-verification time, not raw registration, since
registering an email address doesn't prove you own it - see
auth_service.verify_email / provisioning_service.apply_if_pending.
"""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class PendingProvisioning(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "pending_provisionings"
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
source_user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
make_admin: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -0,0 +1,37 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.pending_provisioning import PendingProvisioning
class PendingProvisioningRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_all(self) -> list[PendingProvisioning]:
result = await self.db.execute(select(PendingProvisioning))
return list(result.scalars().all())
async def get_by_email(self, email: str) -> PendingProvisioning | None:
result = await self.db.execute(
select(PendingProvisioning).where(PendingProvisioning.email == email.lower())
)
return result.scalar_one_or_none()
async def create(
self, email: str, source_user_id: uuid.UUID, make_admin: bool
) -> PendingProvisioning:
record = PendingProvisioning(
email=email.lower(), source_user_id=source_user_id, make_admin=make_admin
)
self.db.add(record)
await self.db.flush()
return record
async def delete(self, record: PendingProvisioning) -> None:
await self.db.delete(record)
await self.db.flush()
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr
class PendingProvisioningCreate(BaseModel):
email: EmailStr
source_user_id: uuid.UUID
make_admin: bool = False
class PendingProvisioningResponse(BaseModel):
id: uuid.UUID
email: str
source_user_id: uuid.UUID
make_admin: bool
created_at: datetime
+6 -1
View File
@@ -52,7 +52,7 @@ from app.schemas.auth import (
TokenResponse,
VerifyEmailRequest,
)
from app.services import ip_throttle_service, security_email_service
from app.services import ip_throttle_service, provisioning_service, security_email_service
EMAIL_CODE_VALID_HOURS = 36
@@ -202,6 +202,11 @@ async def verify_email(db: AsyncSession, client_ip: str, payload: VerifyEmailReq
await code_repo.mark_used(record)
user.email_verified = True
# Verifying an email is the point at which the app can actually trust
# someone owns this address - if an admin queued this address up for
# pre-provisioning (see provisioning_service), this is where it fires,
# never at raw registration (which proves nothing about ownership).
await provisioning_service.apply_if_pending(db, user)
await ip_throttle_service.reset_on_success(db, client_ip, ThrottleAction.VERIFY_EMAIL_CODE)
await UserSecurityEventRepository(db).create(
user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFIED, ip_address=client_ip
@@ -0,0 +1,295 @@
"""Pre-provisions a not-yet-existing account: the moment someone verifies
an email address an admin has queued up (see PendingProvisioning), they
get a deep copy of another user's per-user API keys and companies -
including every tab's worth of data (sources, source documents, monitoring
runs, reports, snapshots, detected changes, enrichment) - plus an email
notification destination for their own address linked to the copied
companies, and optionally an admin promotion.
Applied at email-verification time (see auth_service.verify_email), not
raw registration - registering an email doesn't prove ownership of it,
verifying does.
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import ConflictError, NotFoundError
from app.models.company import Company, CompanyAlias, Competitor
from app.models.company_enrichment import CompanyEnrichment
from app.models.detected_change import DetectedChange
from app.models.enums import NotificationType, SeverityLevel
from app.models.monitor_configuration import MonitorConfiguration
from app.models.monitoring_run import MonitoringRun
from app.models.notification_destination import (
NotificationDestination,
NotificationDestinationCompany,
)
from app.models.pending_provisioning import PendingProvisioning
from app.models.report import Report
from app.models.snapshot import Snapshot
from app.models.source import Source
from app.models.source_document import SourceDocument
from app.models.user import User
from app.models.user_api_key import UserApiKey
from app.repositories.pending_provisioning_repository import PendingProvisioningRepository
from app.repositories.user_repository import UserRepository
def _copy_row[ModelT](
model_cls: type[ModelT], source_row: Any, overrides: dict[str, Any]
) -> ModelT:
"""A new ORM instance of the same class, with every column value copied
from `source_row` except whatever `overrides` replaces (always at least
`id`, plus any foreign keys that need remapping to the new owner's
copies of their parents). Generic over every table involved so adding a
new company-related table later doesn't require touching this file."""
data = {
col.name: getattr(source_row, col.name) for col in model_cls.__table__.columns # type: ignore[attr-defined]
}
data.update(overrides)
return model_cls(**data)
async def _clone_company(
db: AsyncSession, source_company_id: uuid.UUID, target_user_id: uuid.UUID
) -> Company:
id_map: dict[uuid.UUID, uuid.UUID] = {}
def new_id(old_id: uuid.UUID) -> uuid.UUID:
if old_id not in id_map:
id_map[old_id] = uuid.uuid4()
return id_map[old_id]
company = await db.get(Company, source_company_id)
assert company is not None
new_company = _copy_row(Company, company, {"id": new_id(company.id), "user_id": target_user_id})
db.add(new_company)
await db.flush()
for alias in (
await db.execute(select(CompanyAlias).where(CompanyAlias.company_id == source_company_id))
).scalars():
db.add(_copy_row(CompanyAlias, alias, {"id": uuid.uuid4(), "company_id": new_company.id}))
for competitor in (
await db.execute(select(Competitor).where(Competitor.company_id == source_company_id))
).scalars():
db.add(
_copy_row(Competitor, competitor, {"id": uuid.uuid4(), "company_id": new_company.id})
)
config = (
await db.execute(
select(MonitorConfiguration).where(MonitorConfiguration.company_id == source_company_id)
)
).scalar_one_or_none()
if config is not None:
db.add(
_copy_row(
MonitorConfiguration, config, {"id": uuid.uuid4(), "company_id": new_company.id}
)
)
enrichment = (
await db.execute(
select(CompanyEnrichment).where(CompanyEnrichment.company_id == source_company_id)
)
).scalar_one_or_none()
if enrichment is not None:
db.add(
_copy_row(
CompanyEnrichment, enrichment, {"id": uuid.uuid4(), "company_id": new_company.id}
)
)
sources = (
(await db.execute(select(Source).where(Source.company_id == source_company_id)))
.scalars()
.all()
)
for source in sources:
db.add(_copy_row(Source, source, {"id": new_id(source.id), "company_id": new_company.id}))
await db.flush()
for doc in (
await db.execute(
select(SourceDocument).where(SourceDocument.company_id == source_company_id)
)
).scalars():
db.add(
_copy_row(
SourceDocument,
doc,
{
"id": uuid.uuid4(),
"company_id": new_company.id,
"source_id": new_id(doc.source_id),
},
)
)
runs = (
(
await db.execute(
select(MonitoringRun).where(MonitoringRun.company_id == source_company_id)
)
)
.scalars()
.all()
)
for run in runs:
db.add(_copy_row(MonitoringRun, run, {"id": new_id(run.id), "company_id": new_company.id}))
await db.flush()
for report in (
await db.execute(select(Report).where(Report.company_id == source_company_id))
).scalars():
db.add(
_copy_row(
Report,
report,
{
"id": uuid.uuid4(),
"company_id": new_company.id,
"monitoring_run_id": (
new_id(report.monitoring_run_id) if report.monitoring_run_id else None
),
},
)
)
snapshots = (
(await db.execute(select(Snapshot).where(Snapshot.company_id == source_company_id)))
.scalars()
.all()
)
for snapshot in snapshots:
db.add(
_copy_row(
Snapshot,
snapshot,
{
"id": new_id(snapshot.id),
"company_id": new_company.id,
"source_id": new_id(snapshot.source_id) if snapshot.source_id else None,
"monitoring_run_id": (
new_id(snapshot.monitoring_run_id) if snapshot.monitoring_run_id else None
),
},
)
)
await db.flush()
for change in (
await db.execute(
select(DetectedChange).where(DetectedChange.company_id == source_company_id)
)
).scalars():
db.add(
_copy_row(
DetectedChange,
change,
{
"id": uuid.uuid4(),
"company_id": new_company.id,
"source_id": new_id(change.source_id) if change.source_id else None,
"monitoring_run_id": (
new_id(change.monitoring_run_id) if change.monitoring_run_id else None
),
"previous_snapshot_id": (
new_id(change.previous_snapshot_id) if change.previous_snapshot_id else None
),
"current_snapshot_id": (
new_id(change.current_snapshot_id) if change.current_snapshot_id else None
),
},
)
)
return new_company
async def _copy_api_keys(
db: AsyncSession, source_user_id: uuid.UUID, target_user_id: uuid.UUID
) -> None:
keys = (
(await db.execute(select(UserApiKey).where(UserApiKey.user_id == source_user_id)))
.scalars()
.all()
)
for key in keys:
db.add(_copy_row(UserApiKey, key, {"id": uuid.uuid4(), "user_id": target_user_id}))
async def create_pending(
db: AsyncSession, email: str, source_user_id: uuid.UUID, make_admin: bool
) -> PendingProvisioning:
if await UserRepository(db).get_by_email(email) is not None:
raise ConflictError("An account with this email already exists")
if await PendingProvisioningRepository(db).get_by_email(email) is not None:
raise ConflictError("This email is already queued for provisioning")
record = await PendingProvisioningRepository(db).create(email, source_user_id, make_admin)
await db.commit()
return record
async def list_pending(db: AsyncSession) -> list[PendingProvisioning]:
return await PendingProvisioningRepository(db).list_all()
async def delete_pending(db: AsyncSession, pending_id: uuid.UUID) -> None:
repo = PendingProvisioningRepository(db)
match = next((p for p in await repo.list_all() if p.id == pending_id), None)
if match is None:
raise NotFoundError("Pending provisioning entry not found")
await repo.delete(match)
await db.commit()
async def apply_if_pending(db: AsyncSession, new_user: User) -> bool:
"""Called right after a fresh account's email gets verified. Returns
True if a pending provisioning entry matched and was applied (and
consumed - a pending entry only ever fires once)."""
pending = await PendingProvisioningRepository(db).get_by_email(new_user.email)
if pending is None:
return False
await _copy_api_keys(db, pending.source_user_id, new_user.id)
source_companies = (
(await db.execute(select(Company).where(Company.user_id == pending.source_user_id)))
.scalars()
.all()
)
new_company_ids = [
(await _clone_company(db, company.id, new_user.id)).id for company in source_companies
]
if new_company_ids:
destination = NotificationDestination(
user_id=new_user.id,
type=NotificationType.EMAIL,
destination_value=new_user.email,
verified=False,
enabled=True,
minimum_severity=SeverityLevel.MEDIUM,
)
db.add(destination)
await db.flush()
for company_id in new_company_ids:
db.add(
NotificationDestinationCompany(destination_id=destination.id, company_id=company_id)
)
if pending.make_admin:
new_user.is_admin = True
await PendingProvisioningRepository(db).delete(pending)
return True