Overview
DUBTEL AI is a platform where businesses run AI voice agents for outreach and support calls. It is sold through resellers, so the backend has to answer one question correctly on every request: whose data is this, and who is allowed to see it?
I built that backend alone in FastAPI with async SQLAlchemy, asyncpg, and Alembic. I also led the Next.js portal that runs on top of it.
The model
Four levels of access:
| Role | Sees |
|---|---|
| System | Every tenant |
| Tenant Admin | Their whole tenant |
| Account Admin | Their own business client |
| Account User | Their own business client, read-only |
Resources hang off that hierarchy: AI agents and their prompts, phone numbers (including port-in requests), sub-accounts, calendar events, bills, and an activity log. Tenants can white-label the portal with their own logo and a custom domain.
One scoping rule
Every resource router scopes its queries through a single scope_conditions() helper. System gets no filter, tenants get their tenant, and account roles get their business client.
That rule earned its place when one router didn't use it. list_business_clients had its own hand-written tenant_code filter that missed the System case. With one tenant in the database it looked fine. With two, System Admin would have silently lost sight of every other tenant's clients. I moved it onto the shared helper and added a seed script that creates a second tenant, so cross-tenant scoping gets exercised on QA instead of assumed.
The same pass found a trust gap. Agent create and edit accepted a business_client_id from the payload without checking it belonged to the caller's tenant. The API now validates it.
Auth
- Password reset. Tokens are single-use: they're invalidated by a
password_changed_atstamp that every password change updates. Reset links are built from the request'sOriginwhen it's a trusted domain, so QA emails stop linking to production. - Rate limiting across workers. The first forgot-password limiter lived in a module-level dict. The API runs four uvicorn workers, so two quick requests landing on different workers each saw an empty dict, and nothing was ever throttled. It's now backed by a small database table that all workers share.
- SAML 2.0 SSO. An SP-initiated flow built on python3-saml. It stays inert until the
SAML_*variables are set. The assertion consumer never puts a session token in a redirect. It issues a short-lived exchange code in the URL fragment, which the portal swaps for a session. The first SSO login provisions the user just in time. Password and SSO logins share onebuild_login_response(), so their sessions are identical.
Pricing and invoicing
Pricing has three layers: the platform default, a per-tenant wholesale override, and the tenant's own resale price, with floor validation so a tenant can't sell below cost. An in-process APScheduler job generates flat-fee invoices for every active phone number, sub-account, and AI agent on the 1st of each month. Invoices render to PDF on the server with WeasyPrint.
Billing summary cards (unpaid balance, overdue count) got their own endpoint. They had been computed from the current page of rows, so they were quietly wrong once billing ran past one page.
Infrastructure
- Database migration. When the AlloyDB trial ended, I moved the database to a DigitalOcean managed Postgres cluster that requires
sslmode=verify-full. asyncpg doesn't understand libpq-stylesslmodeandsslrootcertparameters, so I build a realssl.SSLContextand pass it throughconnect_args. That had to be done in all three places that create an engine: the app, Alembic, and the seed script. I made the seed script reuse the app's engine instead of building its own. - CI/CD. GitLab CI deploys the
qabranch to QA andmasterto production (manual trigger), using Docker Compose behind nginx. - Audit log. The log table already existed but nothing wrote to it, and any logged-in user could read it. I added a
log_event()helper, called it from every meaningful mutation, and put the endpoint behind the role scoping above.
Results
- A reseller-ready, multi-tenant backend, built and owned end to end by one engineer
- Isolation between tenants enforced by one helper and tested with real second-tenant data
- SSO, audit logging, pricing, and automated invoicing in production shape
