User System Design
How authentication, authorization and entitlement work in TradeEntry v2.0.
Schema reference: teudb Overview. Patch queue: V2.0 Implementation Plan.
Starting point
Before v2.0 there was no authentication of any kind. Every endpoint was public, including POST /api/holidays (full CRUD on a production reference table), all file uploads, and pipeline triggers like POST /api/bhav-downloader/download. On the VPS, CORS was allow_origins=["*"] and access control was described in a code comment as "controlled at firewall level".
The OpenAPI schema at /docs was also public, which made the entire surface self-documenting to anyone who could reach the port.
The five categories
The project leader specified five user categories. They map to six database rows - the sixth, ANON, represents "open to all".
| catcode | rank | Who | Mobile | Sees | |
|---|---|---|---|---|---|
ANON | 0 | Unauthenticated visitor | - | - | Home, Asset Allocation |
FREE | 10 | Identified users | Required | Optional | Public + limited |
PAID | 20 | Subscribers | Required | Mandatory | Per plan |
POWER | 30 | Product evaluators | Required | Mandatory | Analytical modules |
DEVELOPER | 40 | Developers | Required | Required | Everything but user admin |
ADMIN | 50 | Administrators | Required | Required | Everything |
POWER outranks PAID deliberately: evaluators are internal and should see at least what a paying customer sees.
The requirement was "Only Asset allocation page would be visible for all, rest of all pages should come under admin and developers."
So at launch, PAID and POWER categories exist and are fully modelled, but no data pages are granted to them yet. The entitlement rows are the only thing standing between them and access - granting page.option_chain to PAID_PRO later is an INSERT, not a deploy.
The differential verification rule
For first-time users, just email verification is enough. For power users and paid users, mobile number verification is mandatory.
This is enforced by a database CHECK constraint, not by application logic:
CONSTRAINT ck_usr_master_mobile_required
CHECK (catcode IN ('FREE') OR mobile_verified_at IS NOT NULL)
An UPDATE usr_master SET catcode='PAID' on a row without mobile_verified_at raises a constraint violation. A service-layer bug, a careless admin script, or a direct psql session cannot bypass it.
The promotion service must catch IntegrityError and return HTTP 409 with an actionable message - never a 500.
Registration and promotion
DEVELOPER and ADMIN are never self-service. POST /api/admin/users/{usrid}/category grants FREE/PAID/POWER/DEVELOPER to an existing user (never ADMIN — see teudb Overview "Creating the first admin"), and every grant writes an audit row with actor_usrid set. The one ADMIN account is created once via python -m scripts.create_admin, not through this endpoint.
Token design
| Token | Lifetime | Storage | Format |
|---|---|---|---|
| Access | 15 minutes | localStorage | JWT (HS256) |
| Refresh | 14 days | localStorage | Opaque random, sha256-hashed in usr_session |
Access token claims: sub (usrid), cat (catcode), feats (resolved feature codes), entver, jti, iat, exp, typ='access'.
Storing a 14-day refresh token in localStorage means any XSS in the application exfiltrates a long-lived credential. This was a deliberate decision - it works with the existing allow_origins=["*"] CORS policy and needs no cookie/SameSite/nginx work.
Four compensating controls are required, not optional:
- Rotation with family reuse-detection (below)
entverinstant revocation - see teudb Overview- A strict Content-Security-Policy header
- A "sign out everywhere" endpoint that revokes all live sessions
httpOnly cookies are the v2.1 upgrade path.
Refresh rotation and reuse detection
Every refresh rotates: the presented token is revoked, a child is issued in the same family_id. Presenting an already-revoked token revokes the entire family and forces re-login - the signature of a stolen token being replayed.
The Dashboard fires several parallel requests. If each independently calls /refresh on a 401, the second rotation presents a token the first already revoked, the family is killed, and the user is logged out for no reason.
The axios response interceptor must use a single-flight guard so concurrent 401s await one shared refresh promise. This is an explicit acceptance criterion on Patch 13, and it is the most likely bug in the entire frontend workstream.
Instant revocation via entver
Re-resolving entitlements from the database on every request costs a join per request; embedding them in a 15-minute JWT means an entitlement change takes up to 15 minutes to apply. entver gets both properties:
- Resolved features and the current
entverare embedded in the JWT at login. - Each request does one primary-key lookup:
SELECT entver, status FROM usr_master WHERE usrid = $1. - Mismatched
entver, orstatus <> 'active', returns 401. The client refreshes and receives current entitlements. - Every access-changing mutation bumps
entver.
Bump it from a single _bump_entver(session, usrid) helper, called by every mutation in app/auth/service.py. A forgotten bump means a suspended user keeps working for 15 minutes.
Entitlement resolution
Effective entitlements are the union of the category's usr_cat_feature rows and every active subscription's usr_plan_feature rows. can_write is OR-ed; quota_per_day takes the most generous value with NULL meaning unlimited.
Grants are additive only. There is no deny-list. One query, no precedence puzzles.
# app/auth/entitlements.py - the ONLY place this logic lives
async def resolve_features(session, user) -> dict[str, dict]:
"""Returns {featcode: {"read": bool, "write": bool, "quota": int | None}}"""
An anonymous request resolves against the ANON row through the same function. There is no separate anonymous code path anywhere in the backend or the frontend.
Route gating policy
Gating happens at include_router time in main.py, not with per-endpoint decorators:
_auth = [Depends(get_current_user)]
_dev = [Depends(require_min_rank("DEVELOPER"))]
_admin = [Depends(require_min_rank("ADMIN"))]
# PUBLIC
app.include_router(auth_router) # no dependencies
app.include_router(health_router) # split out of api_router
# ADMIN + DEVELOPER (the v2.0 baseline for all data modules)
app.include_router(api_router, dependencies=_dev)
app.include_router(option_chain_router, dependencies=_dev)
app.include_router(bhav_downloader_router, dependencies=_dev)
# ... every remaining data router
# ADMIN only
app.include_router(admin_router, dependencies=_admin)
Why not per-endpoint decorators
Three reasons, in order of importance:
- Completeness by construction. The requirement is "everything except Asset Allocation requires login". With decorators that invariant is spread across roughly 57 endpoints in 10 files, and a reviewer must check every one. A single forgotten decorator is a silent auth bypass. With
include_router(dependencies=...)the entire policy is 14 lines in one file, readable in 30 seconds. - New endpoints are secure by default. Someone adding a route to
option_chain.pynext month inherits the gate without knowing it exists. Decorators fail open by omission; this fails closed. - It composes. Finer-grained checks like
require_feature('bhav_downloader', write=True)still go on individual endpoints in addition - as an extra restriction, never as the sole line of defence.
Public surface in v2.0
| Endpoint | Rationale |
|---|---|
GET / | Liveness |
GET /api/health | Monitoring - must be split out of the gated api_router |
POST /api/auth/* | Registration and login |
/api/aa is not listed: aa_routes is unregistered in Patch 0 because it cannot import. Asset Allocation ships as a static "Under Construction" page requiring no backend.
/docs, /redoc and /openapi.json are set to None when APP_ENV == "VPS". The OpenAPI schema is a complete map of the attack surface and does not belong on a public port.
Holiday master is DEVELOPER, not plain auth
/api/holidays currently offers unauthenticated CRUD on a production reference table. Gating it at DEVELOPER rank rather than plain authentication is deliberate - a FREE user has no business editing the NSE holiday calendar. If read access is wanted for all logged-in users, split the router into read and write halves rather than dropping the whole thing to _auth.
WebSocket authentication
websocket.py currently accepts every connection unconditionally. The handshake gains a token:
@router.websocket("/ws/market-data")
async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(default=None)):
user = await authenticate_ws(token)
if user is None or not user.can_read("live_ws"):
await websocket.close(code=4401)
return
await manager.connect(websocket, usrid=user.usrid)
A query parameter is used rather than a header because the browser WebSocket constructor cannot set headers.
/ws/market-data?token=eyJ... lands in uvicorn's access log. Either disable access logging for /ws, or use the subprotocol trick - new WebSocket(url, ['bearer', token]) read back from Sec-WebSocket-Protocol.
The query parameter is acceptable for v2.0 given a 15-minute token lifetime, provided the logging caveat is handled.
ConnectionManager.active_connections also changes from List[WebSocket] to a dict keyed by socket with the usrid as value, so broadcasts can eventually be filtered per entitlement and a suspended user's socket can be closed.
The Notifier abstraction
Email links and mobile OTPs go through one interface with three implementations, selected by env var:
class Notifier(ABC):
@abstractmethod
async def send(self, *, to: str, subject: str, body: str,
template: str, ctx: dict) -> None: ...
| Implementation | Status in v2.0 |
|---|---|
ConsoleNotifier | Ships. Prints to stdout via loguru. |
SmtpNotifier | Stub - raises NotImplementedError |
SmsNotifier | Stub - raises NotImplementedError |
Selected by EMAIL_NOTIFIER and SMS_NOTIFIER (console | smtp | sms).
ASCII only. It writes via loguru, and per root CLAUDE.md HARD RULE 2 a non-ASCII character crashes on Windows cp1252 and the exception is silently swallowed by a bare except Exception. No arrows, ellipses, or box-drawing characters.
Warn loudly on VPS. If APP_ENV == "VPS" and the notifier is console, log a startup WARNING. A console notifier in production means OTPs land in journalctl - acceptable for a closed beta, but it must be a visible, deliberate choice.
Rollout without locking out the team
Patch 11 gates every data endpoint. The moment it merges, nobody can click through the application until the frontend auth pages land in Patch 14.
Add AUTH_ENFORCED to config, defaulting to false in LOCAL and flipped to true at rollout. The gating dependency short-circuits when it is off.
The test_gating.py sweep runs with the flag forced on regardless, so the protection is still verified on every test run.
This also gives the safest possible rollback for the riskiest patch in the release: flip an env var and restart. No code deploy, no database change.
Cutover sequence for existing anonymous users:
- Deploy Patches 0-11 with
AUTH_ENFORCED=false. Zero user-visible change. - Deploy the frontend through Patch 14. Login and Register appear; nothing is blocked.
- Pre-create accounts for current users via the admin API, setting
email_verified_atdirectly and recording the manual grant in the audit log. Notify them out of band with a password-reset link. - Flip
AUTH_ENFORCED=trueand restart. Announce the date at least a week ahead.
Testing
The enforcement mechanism for the whole access-control requirement is one test:
# backend/tests/test_gating.py
PUBLIC_PATHS = {"/", "/api/health", "/api/auth/*"}
def test_every_api_route_requires_auth(client):
"""Walk app.routes; assert 401 for every /api/* path outside the allowlist."""
This makes "someone adds an endpoint and forgets to gate it" a red build rather than a breach. Keep it green forever.
Constraint-level tests matter equally - promoting a mobile-unverified user to PAID must raise IntegrityError on ck_usr_master_mobile_required. That test is the proof the differential verification rule is enforced at the storage layer and not merely by convention.
Related
- teudb Overview - full schema
- teudb Migrations - the two-environment workflow
- Theme System
- V2.0 Implementation Plan