users-auth-service — business features

Identity, tenants, people, money and the back office — the platform's only public door · ← platform hub · entry points verified against tools/feature-docs/out/users-auth-service.json

1. What it is

users-auth-service decides who you are and what company you belong to: it signs everyone in (admins by password or one-time code, employees by SMS/email code, Oper staff by password plus authenticator app), and it owns the records every other service asks about — companies, people, groups, work locations, job titles, plans, invoices and AI credits. It is also the only service on the public ingress (auth.useoper.com), so it doubles as the front door: the mobile app, the admin web app and the internal back office all talk to it, and the back office's authoring screens are proxied straight through it to authoring-service-v2.

Who uses itTenant admin web app · mobile app (employees) · Oper staff back-office console · every other backend service · one k8s CronJob and four in-pod loops
RuntimeFastAPI + uvicorn · Deployment users-auth-service (1 replica, k8s/users-auth-deployment.yaml:19) · same image also runs Deployments otp-worker and onboarding-enrichment-worker
DatabasePostgres users_auth on the shared platform instance users-auth-postgres; 31 alembic revisions; the only writer (ARCHITECTURE.md §3.4)
Redisusers-auth-redis DB 0 — admin and staff sessions, OTP/registration rate limits, tenant and token-validation caches
NATS streamsOwns AUTH (auth.>, users.*, tenants.*; k8s/nats-screams-cm.yaml:24, created by nats/init.sh:19). Also publishes into MICRO_LEARNING (admin notification envelopes) and AUTHORING_USAGE (cost ledger)
External APIsStripe (checkout, portal, invoices, webhook) · Twenty CRM · Mailgun + Twilio (otp-worker) · OpenRouter (enrichment worker only) · Sentry. The OTP link itself is answered by the Cloudflare edge worker cloudflare-workers/link-otp-worker/
Entry points147 HTTP routes in the scan (+3 the scanner misses, see §4) · 7 consumed NATS subjects · 20 published subjects · 5 background tasks + 1 CronJob

The two satellite workers live here on purpose: their code is users-auth-service/app/otp_worker/ and users-auth-service/app/onboarding_enrichment/, they ship in this service's image, and they only get their own Deployment (k8s/otp-worker-deployment.yaml:61, k8s/onboarding-enrichment-worker-deployment.yaml) so a slow Mailgun call or an LLM call can never sit on the API's event loop. The top-level otp-worker/ and onboarding-enrichment-worker/ directories are empty shells — no tracked source, only __pycache__, tests and stale k8s copies (ARCHITECTURE.md:44). Documenting them here, with this service, is therefore the whole story.

2. Feature map

flowchart LR
  ADMIN["Tenant admin (web app)"]
  EMP["Employee (mobile app)"]
  STAFF["Oper staff (back office)"]
  SVC["Other backend services"]
  SCHED["Schedulers (cron + in-pod loops)"]

  ADMIN --> IDENT["Sign-in, 2FA, sign-up, onboarding (/v1/admin/*)"]
  ADMIN --> PEOPLE["People, groups, locations (/v1/admin/:tenant/*)"]
  ADMIN --> MONEY["Plans, invoices, AI credits (billing, credits)"]
  ADMIN --> COMPLY["GDPR export, deletion queue (exports, deletion-requests)"]
  EMP --> MOBILE["Employee OTP, profile, update gate (/v1/mobile/*)"]
  STAFF --> BACKOFFICE["Tenant 360, billing ops, authoring proxy (/v1/oper-admin/*)"]
  SVC --> INTERNAL["Token check, tenant + people lookups, credit gate (/v1/internal/*, JWKS)"]
  SCHED --> JOBS["OTP worker, enrichment worker, 3 reconcilers, GDPR cron"]

3. Features

Admin

Admin sign-in and session live

tenant admin

An admin signs in with their email and password, or asks for a one-time code and types it in. Either way they end up with a browser session and a 30-minute access token; the session cookie outlives the token, so the panel silently renews it in the background and the admin is not thrown out mid-task. Signing out kills the session and denylists the token.

Entry points
POST /v1/admin/login, POST /v1/admin/sessions, POST /v1/admin/sessions/verify, POST /v1/admin/token/refresh, POST /v1/admin/logout, GET /v1/admin/me, POST|PATCH /v1/admin/password
Touches
users, otp_challenges, user_audit_logs; Redis admin:sess:* + denylist; publishes auth.admin.otp_started
Related
OTP delivery (§3 Background) · token-lifetime defect
Evidence
app/routes/admin_login.py:498, :572, :728, :918, :865, :1038 · guard app/routes/admin_login.py:102 · docs/admin-frontend-integration.md:129 · docs/admin-token-refresh-integration.md:157

Two-factor sign-in (authenticator app) live

tenant admin

An admin turns on 2FA under Account → Security: the panel shows a QR code, the admin scans it and confirms one 6-digit code. From then on password login answers 401 TOTP_REQUIRED until a valid code is supplied. They can switch it off again.

Entry points
GET /v1/admin/totp/status, POST /v1/admin/totp/enroll, POST /v1/admin/totp/verify, POST /v1/admin/totp/disable
Touches
users.totp_secret (alembic 012_admin_totp_and_phone_unique)
Related
The staff console has its own copy of this flow (see Oper staff)
Evidence
app/routes/admin_totp.py:48, :66, :120, :165 · docs/admin-frontend-integration.md:227-295

Company sign-up live

public visitor

Anyone can register a company from the marketing site: they give a company name and their email or phone, receive a one-time code, verify it and choose a password. That creates the tenant and its first OWNER admin in one step, and starts the free trial. Re-submitting the form resumes the half-finished tenant instead of creating a duplicate.

Entry points
POST /v1/admin/registration/register, /verify-otp, /resend-otp, /set-password
Touches
tenants, users, otp_challenges, tenant_billing (trial); publishes auth.admin.otp_started
Related
The deprecated /v1/public/admin/* alias is the same router mounted twice (§4) · rate limiting added for bug-hunt #16
Evidence
app/routes/public_registration.py:284, :541, :619, :727 · mounted app/main.py:304 · docs/admin-frontend-integration.md:36-44

Onboarding wizard, with AI pre-fill live

tenant admin

After the first login the admin walks a five-step wizard: company profile, the job titles they employ, an optional intro meeting. To save typing, they can paste their website and the service hands the job to the enrichment worker, which reads the site and proposes an industry, company size and a list of roles for the admin to accept or edit. The wizard remembers where they stopped.

Entry points
POST /v1/admin/onboarding/start, /steps/{step_key}, /enrich, GET /enrichment, POST /company-profile, GET /role-recommendations, POST /roles, POST /meeting-booked suspect
Touches
tenants.onboarding (JSONB), roles; publishes auth.admin.onboarding.enrich_requested and auth.admin.onboarding.enriched; Twenty CRM
Related
onboarding-enrichment worker (Background) · known defect: the onboarding blob is a read-modify-write race between the worker callback and the admin (bug-hunt #8)
Evidence
app/routes/admin_onboarding.py:92, :101, :148, :211, :254, :344, :390, :436 · docs/admin-onboarding-integration.md:177-387

Company settings and brand live

tenant admin

An admin edits the company name, website, industry, size, logo and brand colour from Settings, and picks the industry from a fixed list the API supplies. Saving announces the change to the rest of the platform.

Entry points
PUT /v1/admin/company, GET /v1/admin/company/industries
Touches
tenants (incl. brand_color, alembic 025_tenant_brand_color); publishes tenants.updated
Related
Before this existed the only writer was an internal X-Oper-Key endpoint (docs/fe-admin-company-settings.md:4-7)
Evidence
app/routes/admin_company.py:142, :152 · docs/fe-admin-company-settings.md:17, :94

An admin's own profile live

tenant admin

An admin changes their own name, date of birth and profile picture. This is the admin-side twin of the mobile profile screen, which they cannot use because their session does not carry the employee scope.

Entry points
PUT /v1/admin/me
Touches
users (name, DOB, icon_url)
Related
Picture upload goes to asset-manager, not here (docs/fe-admin-profile.md §3)
Evidence
app/routes/admin_login.py:1177 · docs/fe-admin-profile.md:5, :97

People directory: employees, admins and educators live

tenant admin

One screen family creates, lists, searches, edits, disables and removes every kind of person in the company. Inviting someone sends them a one-time link; an admin can resend it. What a caller may do depends on their role (OWNER, ADMIN, EDUCATOR) and on the role of the person they are editing, so an educator cannot promote themselves. Job titles are a separate small list the group rules read from.

Entry points
GET|POST /v1/admin/{tenant_id}/users, GET|PATCH|DELETE /users/{user_id}, POST /users/{user_id}/status, /resend-invite, GET|POST /v1/admin/{tenant_id}/roles, and four /users/bulk/* endpoints suspect
Touches
users, roles, group_members, user_audit_logs; publishes users.created, users.updated, users.deleted, auth.user.invitation
Related
Role model: New-Design/SDD-multi-admin.md §4-6, resolved by libs/auth/oper_auth/scopes.py · bulk endpoints 500 on a malformed id (bug-hunt #18)
Evidence
app/routes/users.py:1189, :1208, :1347, :1744, :2227, :2300, :2346, :2403, :2448 · docs/fe-multi-admin-team.md:124, :250, :307 · docs/fe-admin-role-permissions.md:355

One learner's detail page live

tenant admin

Opening a learner shows four cards: their training and engagement totals, their assignments, a timeline of recent activity and their certificates. Only the identity half of that lives here — the rest is fetched live from micro-learning and assignment-service while the admin waits, and a dead source shows as an error card rather than a blank page.

Entry points
GET /v1/admin/{tenant_id}/users/{user_id}/training-summary, /assignments, /activity, /certificates
Touches
users; HTTP to micro-learning and assignment-service (edges H19, H20)
Related
Fan-out client app/clients/training_clients.py:120-167
Evidence
app/routes/user_details.py:86, :146, :238, :335 · guard :65 · docs/fe-admin-user-details.md:23, :86, :103, :142, :172

Groups: hand-picked or rule-driven live

tenant admin

An admin groups people to assign training to them. A static group is a list they curate by hand; a dynamic group is a rule ("every cashier in Berlin") and the service keeps its membership correct by itself — immediately when someone is hired, changes job title or leaves, and again on a periodic sweep in case an event was missed. Before saving a rule the admin can preview who it would catch, and re-resolve a group on demand.

Entry points
GET|POST /v1/admin/{tenant_id}/groups, GET|PUT|DELETE /groups/{group_id}, member add/update/remove plus members:bulk, groups:preview-membership, groups/{group_id}:resolve, groups/role-titles
Touches
groups, group_members; consumes users.*; publishes groups.updated
Related
Resolver internals app/services/groups_resolver.py · phone-only employees used to break membership (bug-hunt #5, fixed by alembic 020_group_members_email_nullable) · a failing group no longer discards the whole sweep (bug-hunt #10)
Evidence
app/routes/groups.py:346, :505, :623, :662, :695, :871, :923, :1030, :1239, :1382 · docs/admin-groups-integration.md:16, :319, :647

Work locations live

tenant admin

An admin maintains the company's stores, sites and offices — address, map pin, active or disabled — and can delete several at once. Employees pick their location on the mobile app, and group rules can target one.

Entry points
GET|POST /v1/admin/{tenant_id}/locations, PATCH|DELETE /locations/{location_id}, POST /locations/bulk-delete
Touches
locations, users.store_id, tenant_audit_logs
Related
Mobile side: GET /v1/mobile/locations · bulk delete shares bug-hunt #18
Evidence
app/routes/locations.py:192, :278, :386, :456, :495 · docs/admin-locations-integration.md:94, :204, :271

Subscription, invoices and seats live

tenant admin

An admin sees which plan the company is on, how many monthly active learners it has used, and what the next invoice looks like; they can switch plan, start a Stripe checkout, open the Stripe billing portal and download past invoices. Stripe tells the service about payments through a signed webhook, and a periodic sweep keeps the usage figure honest between invoices.

Entry points
GET /v1/admin/{tenant_id}/billing/overview, /plans, /invoices, POST /billing/checkout, /billing/portal, PUT /billing/plan, POST /v1/billing/stripe/webhook
Touches
billing_plans, tenant_billing, billing_invoices, billing_events; Stripe; HTTP to micro-learning for active-learner counts (H19)
Related
Pricing model in docs/fe-admin-billing.md §TL;DR · trials and Enterprise contracts in New-Design/SDD-trial-and-enterprise.md
Evidence
app/routes/admin_billing.py:266, :336, :370, :414, :443, :458, :528 · docs/fe-admin-billing.md:46-51, :236

AI credits live

tenant admin

Course generation costs credits. An admin sees the balance, the history of what spent it, and can top up. The same ledger is what authoring and micro-learning check before starting an AI run, so a company that runs out is told before the run rather than after.

Entry points
GET /v1/admin/{tenant_id}/credits/balance, /credits/history, POST /credits/topup
Touches
credit_balances, credit_ledger; Stripe (top-up)
Related
Internal gate below · design New-Design/SDD-credit-ledger.md
Evidence
app/routes/credits.py:212, :223, :254 · authoring-service-v2/docs/fe-import-estimate.md:111-113 · New-Design/SDD-credit-ledger.md:64-66

Answering a data-access request (GDPR) live

tenant admin

An admin asks for everything the platform holds about one learner. The request is queued, not answered on the spot: overnight a job collects the learner's profile, groups, preferences, training history, assignments, feed activity and notifications from five services into one bundle, tells the admins it is ready, and the admin downloads it.

Entry points
POST /v1/admin/users/{user_id}/export, GET /v1/admin/exports, /exports/{job_id}, /exports/{job_id}/download
Touches
gdpr_export_jobs; HTTP GET /v1/internal/export/{user_id} on micro-learning, assignment, feed and notification-worker (H23); publishes an admin notification envelope
Related
CronJob users-auth-process-gdpr-exports (Background) · a missing source URL marks the bundle incomplete rather than failing
Evidence
app/routes/admin_export.py:88, :196, :242, :272 · app/services/gdpr_export.py:144-156, :350 · docs/fe-admin-gdpr-export.md:13, :81, :113

Deletion queue live

tenant admin

Learners can ask to have their account deleted, but the decision stays with the company: admins see the open requests with the stated reason and either process one (which soft-deletes the account and tells the rest of the platform) or cancel it.

Entry points
GET /v1/admin/deletion-requests, POST /deletion-requests/{request_id}/process, /cancel
Touches
account_deletion_requests, users.deleted_at; publishes users.deleted
Related
Mobile side below · docs/fe-account-deletion.md
Evidence
app/routes/account_deletion.py:235, :313, :404 · docs/fe-account-deletion.md:92, :132-133

Oper staff (back office)

Staff sign-in live

oper staff

Oper's own people sign in to the internal console with a password and a 6-digit code from their authenticator app. A staff identity belongs to no company, which is exactly why the console needs the proxy below to author anything.

Entry points
POST /v1/oper-admin/session, /token/refresh, /logout, GET /v1/oper-admin/me, GET|POST /v1/oper-admin/totp/status|enroll|verify
Touches
staff_users (alembic 028_staff_users); Redis staff sessions
Related
The first staff account is created by the bootstrap Job k8s/job-bootstrap-staff-master.yaml
Evidence
app/routes/oper_staff_auth.py:202, :241, :281, :313, :335, :349, :380 · docs/fe-backoffice.md:15, :38, :61, :79-81

Tenant 360 live

oper staff

One read-only screen per customer: who they are, their plan and status, how much they use the product, their credit balance and burn rate, their people, and the state of their course generation runs. Staff answer "is this customer healthy?" without touching the customer's own panel. Nothing on this screen mutates anything.

Entry points
GET /v1/oper-admin/tenants, /tenants/{tenant_id}, /usage, /credits, /users, /pipeline, /pipeline/runs/{run_id}
Touches
reads tenants, users, tenant_billing, credit_*; HTTP to micro-learning analytics, notification-worker devices and authoring-v2 pipeline stats
Related
Aggregation helpers app/services/backoffice.py, clients app/clients/backoffice_clients.py:93-294
Evidence
app/routes/oper_backoffice.py:51, :94, :139, :164, :179, :211, :280 · docs/fe-backoffice.md:123-129

Creating and suspending customers live

oper staff

Staff create a tenant with its first admin, rename it, suspend or reactivate it, and (rarely) delete it outright. Suspension is what stops a non-paying company from using the product.

Entry points
POST /v1/oper-admin/tenants, PATCH|DELETE /v1/oper-admin/tenants/{tenant_id}, POST /tenants/{tenant_id}/status
Touches
tenants, users, tenant_audit_logs; publishes tenants.created, tenants.updated, tenants.deleted
Related
Known defect: the hard-delete audit row is cascade-deleted in the same transaction, so the deletion is never recorded (bug-hunt #9)
Evidence
app/routes/admin.py:71, :201, :286, :338 · docs/fe-backoffice.md:124

Manual billing and Enterprise contracts live

oper staff

Not every customer pays by card. Staff can set a tenant's plan and seat allowance by hand, record an Enterprise contract with its monthly credit allowance, raise and amend manual invoices, and edit the plan catalogue. Putting a tenant on an Enterprise contract also triggers a welcome email and the first month's credit grant.

Entry points
GET|PUT /v1/oper-admin/tenants/{tenant_id}/billing, POST /billing/enterprise, POST /tenants/{tenant_id}/invoices, PATCH /v1/oper-admin/invoices/{invoice_id}, PUT /v1/oper-admin/billing/plans/{plan_key}
Touches
tenant_billing, billing_plans, billing_invoices, credit_ledger; publishes auth.admin.enterprise_welcome
Related
Monthly grants after the first are the entitlements reconciler's job (Background)
Evidence
app/routes/admin_billing.py:655, :698, :733, :846, :885, :918 · app/services/entitlements.py:226 · docs/fe-admin-billing.md:218-268 · docs/fe-backoffice.md:142-143

Authoring the catalogue through the proxy live

oper staff

Oper's own team writes the courses the marketplace sells. Rather than a second authoring API, the console calls /v1/oper-admin/authoring/... and this service forwards the call verbatim to authoring-service-v2 — the module shelf, the draft tree, every builder tool, publishing, run polling and the creation chat. A staff operator has no company, so the proxy mints a five-minute admin token for one fixed internal tenant per hop; it can never reach a customer's content, whatever path is asked for.

Entry points
GET|POST|PUT|PATCH|DELETE /v1/oper-admin/authoring/{path:path} (catch-all), POST /v1/oper-admin/authoring/socket-token suspect
Touches
reads one ADMIN row of the internal tenant (cached 60s); HTTP to authoring-service-v2 /v1/admin/authoring/* (edge H22) over a pooled client opened in the lifespan
Related
Two staff reads bypass the proxy and go to micro-learning instead (next block) · the websocket is not proxied (docs/fe-backoffice.md:186)
Evidence
app/routes/oper_authoring.py:369, :398, :300 · pool opened app/main.py:119 · docs/fe-backoffice.md:130, :153-190

Is this module published, and in which languages? live

oper staff

Two questions the authoring API cannot answer because the answer lives in micro-learning: which translations of a published module exist, and which version is currently live. The console asks this service and it forwards the read.

Entry points
GET /v1/oper-admin/modules/{module_id}/translations, GET /v1/oper-admin/modules/{root_id}/latest
Touches
HTTP to micro-learning (TRAINING_SERVICE_URL, edge H19)
Related
Retrying a failed translation happens through the authoring proxy (docs/fe-backoffice.md:133-135)
Evidence
app/routes/oper_training.py:105, :121, target :53-60 · docs/fe-backoffice.md:131, :136

Employee (mobile)

Sign in with a one-time code live

employee

An employee types their phone number or email into the app, gets a code by SMS or email, and is signed in — no password to remember, which is the point for frontline staff. Logging out denylists the token so it cannot be reused.

Entry points
POST /v1/mobile/sessions/otp/start, /otp/verify, POST /v1/mobile/logout
Touches
users, otp_challenges; Redis rate limits + denylist; publishes auth.user.otp_started, auth.employee_login_success, auth.employee_logout
Related
Known defects: the lookup is not tenant-scoped, so a dual-employed person cannot choose their company (bug-hunt #6); verify does not re-check that the account is still active (bug-hunt #3, #7)
Evidence
app/routes/mobile_login.py:251, :328, :725 · in-repo caller global-configs/management_scripts/login_employee_otp.py:35 (the app itself is not in this repo)

Who am I, and my profile live

employee

Every app open asks this service who the user is: name, company, job title, work location, language, and a small training summary for the home screen. The same screen family lets the employee edit their name, birthday, language, picture and work location; finishing onboarding notifies the company's admins that a new learner is ready.

Entry points
GET /v1/mobile/me, PUT /v1/mobile/me
Touches
users, locations; HTTP to micro-learning for the summary (H19); publishes an admin notification envelope on first completion
Related
Hot path: the DB connection is now released before the training fan-out, which used to exhaust the pool during a micro-learning slowdown (bug-hunt #11, fixed)
Evidence
app/routes/mobile_login.py:624 · app/routes/mobile_profile.py:273, publish :216 · docs/fe-admin-profile.md:5

Forced app update live

employee

The app never decides for itself whether it is too old. It sends its build number on every call; the server answers with "fine", "please update" or "you must update", and every mobile call from an unsupported build is refused with 426 until the user updates. Operators raise the floor through an internal endpoint, so the policy changes without shipping an app release.

Entry points
GET /v1/mobile/config (exempt from the gate), the 426 middleware on all other /v1/mobile/*, GET|PUT /v1/internal/app-versions[/{platform}]
Touches
app_versions, app_version_events (alembic 024_app_versions)
Related
Fail-open by design; APP_UPDATE_FORCE_STATUS is a QA override that shouts at boot if left armed
Evidence
app/main.py:215-233 · app/routes/mobile_config.py:50 · app/routes/internal_app_versions.py:133, :147 · docs/fe-app-update-gate.md:54, :150-170

Where I work live

employee

The profile screen needs the company's list of stores and sites so the employee can say which one they work at. This is the read-only employee view of the admin's locations list.

Entry points
GET /v1/mobile/locations
Touches
locations
Related
Admin side: Work locations
Evidence
app/routes/mobile_locations.py:18 · in-repo caller global-configs/management_scripts/mobile_profile_update.py:74

Notification preferences suspect

employee

An employee chooses which categories of notification (training, feed) they want by push or email. Every notification sender checks these preferences before delivering, so switching a category off actually stops the pushes.

Entry points
GET|PUT /v1/mobile/notification/preferences suspect, GET /v1/admin/{tenant_id}/users/{user_id}/notification/preferences live, POST /v1/internal/notification-preferences/batch live
Touches
notification_preferences (alembic 009, 011)
Related
Readers: notification-worker, assignment-service, micro-learning
Evidence
app/routes/notification_preferences.py:123, :166, :237 · readers notification-worker/app/services/preferences_service.py:87, assignment-service/app/services/users_service.py:28, :102 · no in-repo caller or FE doc for the two mobile routes (searched users-auth-service/docs/*.md and New-Design/*.md); only scripts/smoke_test.py:431 exercises them

Asking for my account to be deleted live

employee

An employee can file a deletion request with a reason and see its state. The account is not deleted on the spot — an admin has to process it — and a cancelled request tells the app the user may file again.

Entry points
POST /v1/mobile/me/deletion-request, GET /v1/mobile/me/deletion-request
Touches
account_deletion_requests (alembic 027)
Related
Admin side: Deletion queue
Evidence
app/routes/account_deletion.py:121, :206 · docs/fe-account-deletion.md:16, :30-33, :80

Internal (other services)

Is this token real, and who does it belong to? live

every service

Every other service asks this service to vouch for a caller's token before doing anything for them. There are two ways: a direct check here (which also honours logout, because a revoked token is on a denylist a signature check cannot see), or verifying the signature locally against the published signing keys. If this endpoint is down, nobody can serve an authenticated request — it is the platform's single busiest dependency.

Entry points
POST /v1/internal/validate-token, GET /v1/.well-known/jwks.json
Touches
users, tenants; Redis denylist + a 2s in-process cache; JWT keys mounted from the jwt-keys secret
Related
Shared client libs/auth/oper_auth/token_validation.py:77 · known defects: the validation cache is unbounded (bug-hunt #13) and the admin-scope test is a substring match (bug-hunt #4/#20)
Evidence
app/routes/internal.py:149 · app/routes/jwks.py:10 · callers assignment-service/app/services/users_service.py:121, authoring-service-v2/app/core/security.py:56, notification-worker/app/services/auth_service.py:51, libs/auth/oper_auth/service/fastapi.py:41; JWKS URL configured in asset-manager-service/app/core/auth.py:37, assignment-service/app/core/config.py:35, authoring-service-v2/app/core/config.py:55, feed-service/app/core/config.py:23

Which company is this? live

authoring-v2

Services that render or bill on behalf of a company need its name, slug, status and onboarding facts. They ask by tenant id; the answer is cached in Redis because it is read on hot paths.

Entry points
GET /v1/internal/tenants/{tenant_id}, GET /v1/internal/tenants/slug/{slug} dead
Touches
tenants; Redis tenant cache
Related
Known defect: an unguarded Redis call turns a Redis blip into 500s for every calling service even though a DB fallback exists (bug-hunt #12)
Evidence
app/routes/internal.py:258, :285 · caller authoring-service-v2/app/core/http.py:489, :514

Who works here, and what are they called? live

assignment · micro-learning · feed · authoring-v2 · notification-worker

Four services render names, avatars and job titles next to content they own but do not store people for. They fetch a whole company roster, a directory, a batch of profiles by id, the company's admins, or a registration-over-time series for the admin dashboard. Answers are gzip-compressed because the directory is unpaginated.

Entry points
GET /v1/internal/tenants/{tenant_id}/users, /directory, /admins, /users/registration-series, POST /v1/internal/users/profiles/batch
Touches
users, locations, roles
Related
Known defects: the profile batch is not tenant-scoped (bug-hunt #19); the roster path has a shadowed duplicate handler (§8)
Evidence
app/routes/internal.py:331, :384, :503, :730, :830 · callers assignment-service/app/services/users_service.py:66, :194, micro-learning-service-v2/app/clients/user_service_client.py:89, :129, :142, :191, authoring-service-v2/app/core/http.py:542, feed-service/app/clients/users_auth_client.py:120, :162, notification-worker/app/services/admins_client.py:73

Who is in this group? live

assignment-service

When an admin assigns training to a group, assignment-service asks this service to expand the group into actual people, and later asks for several groups' details at once to label them. This is the seam that turns "the Berlin cashiers" into a list of learners to notify.

Entry points
GET /v1/internal/tenants/{tenant_id}/groups/{group_id}/members, POST /v1/internal/groups/batch
Touches
groups, group_members, users
Related
The member expansion is an N+1 per group on the assignment path (ARCHITECTURE.md §3.2 H24)
Evidence
app/routes/internal.py:452, :572 · callers assignment-service/app/services/groups_service.py:27, :46, :56

Can this company afford this AI run? live

authoring-v2 · micro-learning

Before an expensive generation starts, authoring quotes it and debits the company's credits here; if the run fails or settles cheaper, it refunds the difference. Micro-learning does the same at publish. A company with no credits is stopped at the gate instead of being surprised by a bill.

Entry points
GET /v1/internal/credits/balance, POST /v1/internal/credits/debit, /refund, /grant dead
Touches
credit_balances, credit_ledger
Related
Admin-facing balance and top-up above · New-Design/SDD-credit-ledger.md
Evidence
app/routes/credits.py:136, :148, :168, :188 · callers authoring-service-v2/app/core/http.py:573, :599, :645, :667 and micro-learning-service-v2/app/clients/credits_client.py:52, :66, :93, :113

Notification preferences in bulk live

assignment-service · notification-worker · micro-learning

Before a fan-out of hundreds of pushes, the sender asks once for all the recipients' preferences instead of one call per person. Without this, a group notification would be one HTTP round-trip per learner on the sender's event loop.

Entry points
POST /v1/internal/notification-preferences/batch, GET /v1/admin/{tenant_id}/users/{user_id}/notification/preferences
Touches
notification_preferences
Related
notification-worker caches the answer for 120s (notification-worker/app/core/config.py)
Evidence
app/routes/internal.py:646 · app/routes/notification_preferences.py:237 · callers assignment-service/app/services/users_service.py:28, :102, micro-learning-service-v2/app/clients/user_service_client.py:157, notification-worker/app/services/preferences_service.py:87

Enrichment write-back live

onboarding-enrichment-worker

The enrichment worker cannot reach the database, so when its AI job finishes it posts the proposal (or a failure) back through this endpoint, which stores it on the tenant's onboarding record for the wizard to show.

Entry points
POST /v1/internal/admin/onboarding/enrichment-result
Touches
tenants.onboarding
Related
Edge H27 · races the admin's own wizard writes (bug-hunt #8)
Evidence
app/routes/admin_onboarding.py:475 · caller app/onboarding_enrichment/result_client.py:25, :76 · docs/onboarding-enrichment-worker.md:26

Background

Delivering codes, invitations and alerts (otp-worker) live

otp-worker Deployment

Nothing in the API pod ever talks to Mailgun or Twilio. When someone asks for a code, is invited, or an admin has an alert worth emailing, the API publishes an event and this worker delivers it — email or SMS, with retries and a dead-letter record. That is why a Twilio outage slows deliveries instead of failing logins.

Entry points
5 push durables on AUTH: auth.admin.otp_started, auth.user.otp_started, auth.user.invitation, auth.admin.alert, auth.admin.enterprise_welcome
Touches
no database; Mailgun, Twilio; publishes auth.otp_failed on give-up
Related
The link in the message is answered by the Cloudflare worker cloudflare-workers/link-otp-worker/; invitation URLs /i/{slug} currently hit that worker's 404 (ARCHITECTURE.md §4.1(8)) · DLQ loss risk in bug-hunt-reports/otp-worker.md #2
Evidence
app/otp_worker/processor.py:119-141 (subscribe), :157-168 (dispatch), :389 (DLQ) · subjects app/otp_worker/config.py:32-47 · k8s/otp-worker-deployment.yaml:61 · docs/otp-worker.md:16-21

Reading a company's website so the admin types less (onboarding-enrichment-worker) live

onboarding-enrichment-worker Deployment

One job: take a company's website, read it, and propose an industry, a size and a list of job titles. It is the only component in the identity stack that calls an LLM, and it holds the OpenRouter key so the API pod does not have to. Results come back over HTTP; a run that keeps failing is marked FAILED so the wizard stops waiting.

Entry points
1 durable onboarding-enrichment-worker on auth.admin.onboarding.enrich_requested
Touches
no database; website fetch + OpenRouter; HTTP back to /v1/internal/admin/onboarding/enrichment-result; publishes authoring.usage.event for the cost ledger
Related
Admin-facing wizard above · docs/onboarding-enrichment-worker.md
Evidence
app/onboarding_enrichment/processor.py:110-133 (subscribe), :188 (heartbeat), :364 (usage) · config app/onboarding_enrichment/config.py:11-32 · k8s/onboarding-enrichment-worker-deployment.yaml:35

Keeping dynamic groups true live

in the API pod

A rule-based group has to stay correct without anyone pressing a button. Two layers do that: an event subscriber re-resolves affected groups seconds after a person is hired, edited or removed, and a periodic sweep re-resolves everything in case an event was lost or the pod was down during a deploy. An admin sees membership that matches the rule, not a snapshot.

Entry points
pull durable users_auth_groups_resolver on users.*; dynamic-groups reconciler loop
Touches
groups, group_members, users
Related
The blocking DB work runs on a worker thread because this pod also serves JWKS and token validation · each group now resolves inside its own SAVEPOINT (bug-hunt #10, fixed)
Evidence
app/services/groups_events_subscriber.py:143, :196, :36-47, :50-58 · started app/main.py:135 · app/services/reconcilers.py:294

Money that has no webhook: trials and monthly contracts live

in the API pod

Two money facts nothing reactive can tell us: a free trial's end date arriving, and each later month of an Enterprise contract's credit allowance. An hourly sweep owns both, so an expired trial does not read as full access for the rest of the day and a contract's credits keep arriving. A third sweep keeps the monthly-active-learner figure fresh between Stripe invoices.

Entry points
entitlements and billing-seats reconciler loops
Touches
tenant_billing, credit_balances, credit_ledger; micro-learning active-learner counts; Stripe
Related
Both are idempotent and single-flight behind a Postgres advisory lock, so extra replicas skip rather than double-grant · the trial sweep deliberately runs even with Stripe dark
Evidence
app/services/reconcilers.py:196, :242, :300, :306 · started app/main.py:145-146 · New-Design/SDD-trial-and-enterprise.md

Building the export bundles overnight live

k8s CronJob

At 02:00 UTC a job works through the queued data-access requests: for each learner it collects this service's own records and asks micro-learning, assignment-service, feed-service and notification-worker for theirs, stores one bundle, and tells the company's admins it is ready. A source that is unreachable is recorded as missing rather than failing the whole export.

Entry points
CronJob users-auth-process-gdpr-exportsscripts/cron_process_gdpr_exports.py
Touches
gdpr_export_jobs; HTTP GET /v1/internal/export/{user_id} ×4 (H23); publishes an admin notification envelope
Related
Admin-facing export screens above · the schedule must stay in sync with GDPR_EXPORT_WINDOW_HOUR_UTC or admins are told the wrong time (k8s/cron-process-gdpr-exports.yaml:12-16)
Evidence
k8s/cron-process-gdpr-exports.yaml:16, :34 · app/services/gdpr_export.py:144-156, :175, :350 · kept as a CronJob on purpose: app/services/reconcilers.py:35-38

4. API reference

Every route the scanner found, in scan order (147 rows — the row count matches tools/feature-docs/out/users-auth-service.json). “Auth” is what the handler actually enforces, not what the prefix suggests: the admin panel authenticates with the oper_sess session cookie (not a bearer token), and mutating admin routes additionally require a multi-admin scope resolved from the caller's role (app/routes/admin_login.py:102-161, libs/auth/oper_auth/scopes.py).

Registration is mounted twice. public_registration_router is included at the canonical /v1/admin/registration (app/main.py:304) and again at the deprecated, schema-hidden /v1/public/admin (app/main.py:306). Both prefixes reach the same four handlers; the alias rows carry their own verdict.

MethodPathAuthFeatureCallersVerdict
GET/publicService rootnone foundsuspect
POST/debug/observabilitypublicDebug pingnone (DEBUG=false in prod)dead
GET/healthzpublicHealthk8s probeslive
GET/v1/.well-known/jwks.jsonpublicJWKSasset-manager, assignment, asv2, feedlive
PUT/v1/admin/companyadmin session cookie + settings.writeCompany settingsadmin web applive
GET/v1/admin/company/industriesadmin session cookieCompany settingsadmin web applive
GET/v1/admin/deletion-requestsadmin session cookieDeletion queueadmin web applive
POST/v1/admin/deletion-requests/{request_id}/canceladmin session cookie + users.deleteDeletion queueadmin web applive
POST/v1/admin/deletion-requests/{request_id}/processadmin session cookie + users.deleteDeletion queueadmin web applive
GET/v1/admin/exportsadmin session cookie + export.readGDPR exportadmin web applive
GET/v1/admin/exports/{job_id}admin session cookie + export.readGDPR exportadmin web applive
GET/v1/admin/exports/{job_id}/downloadadmin session cookie + export.readGDPR exportadmin web applive
POST/v1/admin/loginpublic (password)Admin sign-inadmin web applive
POST/v1/admin/logoutadmin session cookieAdmin sign-inadmin web applive
GET/v1/admin/meadmin session cookieAdmin sign-inadmin web applive
PUT/v1/admin/meadmin session cookieAdmin sign-inadmin web applive
POST/v1/admin/onboarding/company-profileadmin session cookie + settings.writeOnboarding wizardadmin web applive
POST/v1/admin/onboarding/enrichadmin session cookie + settings.writeOnboarding wizardadmin web applive
GET/v1/admin/onboarding/enrichmentadmin session cookieOnboarding wizardadmin web applive
POST/v1/admin/onboarding/meeting-bookedadmin session cookie + settings.writeOnboarding wizardnone foundsuspect
GET/v1/admin/onboarding/role-recommendationsadmin session cookieOnboarding wizardadmin web applive
POST/v1/admin/onboarding/rolesadmin session cookie + settings.writeOnboarding wizardadmin web applive
POST/v1/admin/onboarding/startadmin session cookie + settings.writeOnboarding wizardadmin web applive
POST/v1/admin/onboarding/steps/{step_key}admin session cookie + settings.writeOnboarding wizardadmin web applive
PATCH/v1/admin/passwordadmin session cookieAdmin sign-inadmin web applive
POST/v1/admin/passwordadmin session cookieAdmin sign-inadmin web applive
POST/v1/admin/registration/registerpublicCompany registrationadmin web applive
POST/v1/admin/registration/resend-otppublicCompany registrationadmin web applive
POST/v1/admin/registration/set-passwordpublicCompany registrationadmin web applive
POST/v1/admin/registration/verify-otppublicCompany registrationadmin web applive
POST/v1/admin/sessionspublic (OTP start)Admin sign-inadmin web applive
POST/v1/admin/sessions/verifypublic (OTP verify)Admin sign-inadmin web applive
POST/v1/admin/token/refreshadmin session cookieAdmin sign-inadmin web applive
POST/v1/admin/totp/disableadmin session cookieAdmin TOTPadmin web applive
POST/v1/admin/totp/enrolladmin session cookieAdmin TOTPadmin web applive
GET/v1/admin/totp/statusadmin session cookieAdmin TOTPadmin web applive
POST/v1/admin/totp/verifyadmin session cookieAdmin TOTPadmin web applive
POST/v1/admin/users/{user_id}/exportadmin session cookie + export.readGDPR exportadmin web applive
POST/v1/admin/{tenant_id}/billing/checkoutadmin session cookie + billing.writeBilling & plansadmin web applive
GET/v1/admin/{tenant_id}/billing/invoicesadmin session cookie + billing.writeBilling & plansadmin web applive
GET/v1/admin/{tenant_id}/billing/overviewadmin session cookie + billing.writeBilling & plansadmin web applive
PUT/v1/admin/{tenant_id}/billing/planadmin session cookie + billing.writeBilling & plansadmin web applive
GET/v1/admin/{tenant_id}/billing/plansadmin session cookie + billing.writeBilling & plansadmin web applive
POST/v1/admin/{tenant_id}/billing/portaladmin session cookie + billing.writeBilling & plansadmin web applive
GET/v1/admin/{tenant_id}/credits/balanceadmin session cookie + credits.readAI creditsadmin web applive
GET/v1/admin/{tenant_id}/credits/historyadmin session cookie + credits.readAI creditsadmin web applive
POST/v1/admin/{tenant_id}/credits/topupadmin session cookie + billing.writeAI creditsadmin web applive
GET/v1/admin/{tenant_id}/groupsadmin session cookieGroupsadmin web applive
POST/v1/admin/{tenant_id}/groupsadmin session cookie + users.writeGroupsadmin web applive
GET/v1/admin/{tenant_id}/groups/role-titlesadmin session cookieGroupsadmin web applive
DELETE/v1/admin/{tenant_id}/groups/{group_id}admin session cookie + users.writeGroupsadmin web applive
GET/v1/admin/{tenant_id}/groups/{group_id}admin session cookieGroupsadmin web applive
PUT/v1/admin/{tenant_id}/groups/{group_id}admin session cookie + users.writeGroupsadmin web applive
GET/v1/admin/{tenant_id}/groups/{group_id}/membersadmin session cookieGroupsadmin web applive
POST/v1/admin/{tenant_id}/groups/{group_id}/membersadmin session cookie + users.writeGroupsadmin web applive
DELETE/v1/admin/{tenant_id}/groups/{group_id}/members/{member_id}admin session cookie + users.writeGroupsadmin web applive
PUT/v1/admin/{tenant_id}/groups/{group_id}/members/{member_id}admin session cookie + users.writeGroupsadmin web applive
DELETE/v1/admin/{tenant_id}/groups/{group_id}/members:bulkadmin session cookie + users.writeGroupsadmin web applive
POST/v1/admin/{tenant_id}/groups/{group_id}/members:bulkadmin session cookie + users.writeGroupsadmin web applive
POST/v1/admin/{tenant_id}/groups/{group_id}:resolveadmin session cookie + users.writeGroupsadmin web applive
POST/v1/admin/{tenant_id}/groups:preview-membershipadmin session cookie + users.writeGroupsadmin web applive
GET/v1/admin/{tenant_id}/locationsadmin session cookieLocationsadmin web applive
POST/v1/admin/{tenant_id}/locationsadmin session cookie + settings.writeLocationsadmin web applive
POST/v1/admin/{tenant_id}/locations/bulk-deleteadmin session cookie + settings.writeLocationsadmin web applive
DELETE/v1/admin/{tenant_id}/locations/{location_id}admin session cookie + settings.writeLocationsadmin web applive
PATCH/v1/admin/{tenant_id}/locations/{location_id}admin session cookie + settings.writeLocationsadmin web applive
GET/v1/admin/{tenant_id}/rolesadmin session cookieJob titles (roles)admin web applive
POST/v1/admin/{tenant_id}/rolesadmin session cookie + users.writeJob titles (roles)admin web applive
GET/v1/admin/{tenant_id}/usersadmin session cookieEmployee & admin directoryadmin web applive
POST/v1/admin/{tenant_id}/usersadmin session cookie + scope by target typeEmployee & admin directoryadmin web applive
POST/v1/admin/{tenant_id}/users/bulk/deleteadmin session cookie + users.deleteEmployee directory (bulk)admin web appsuspect
POST/v1/admin/{tenant_id}/users/bulk/roleadmin session cookie + users.writeEmployee directory (bulk)admin web appsuspect
POST/v1/admin/{tenant_id}/users/bulk/statusadmin session cookie + users.writeEmployee directory (bulk)admin web appsuspect
POST/v1/admin/{tenant_id}/users/bulk/storeadmin session cookie + users.writeEmployee directory (bulk)admin web appsuspect
DELETE/v1/admin/{tenant_id}/users/{user_id}admin session cookieEmployee & admin directoryadmin web applive
GET/v1/admin/{tenant_id}/users/{user_id}admin session cookieEmployee & admin directoryadmin web applive
PATCH/v1/admin/{tenant_id}/users/{user_id}admin session cookieEmployee & admin directoryadmin web applive
GET/v1/admin/{tenant_id}/users/{user_id}/activityadmin session cookieUser detail pageadmin web applive
GET/v1/admin/{tenant_id}/users/{user_id}/assignmentsadmin session cookieUser detail pageadmin web applive
GET/v1/admin/{tenant_id}/users/{user_id}/certificatesadmin session cookieUser detail pageadmin web applive
GET/v1/admin/{tenant_id}/users/{user_id}/notification/preferencesadmin JWT (admin.panel)Notification preferencesassignment, micro-learning, notification-workerlive
POST/v1/admin/{tenant_id}/users/{user_id}/resend-inviteadmin session cookieEmployee & admin directoryadmin web applive
POST/v1/admin/{tenant_id}/users/{user_id}/statusadmin session cookieEmployee & admin directoryadmin web applive
GET/v1/admin/{tenant_id}/users/{user_id}/training-summaryadmin session cookieUser detail pageadmin web applive
POST/v1/auth/refreshtoken in bodyMobile token refreshmobile app (out of repo)live
POST/v1/auth/tokenX-Oper-KeyMachine token mintnone founddead
POST/v1/billing/stripe/webhookStripe signatureBilling & plansStripe (external)live
POST/v1/internal/admin/onboarding/enrichment-resultX-Oper-KeyOnboarding wizardonboarding-enrichment-workerlive
PUT/v1/internal/app-versions/{platform}X-Oper-KeyApp-update gateoperators (X-Oper-Key)live
POST/v1/internal/auth/tokenX-Oper-KeyMachine token mintnone founddead
GET/v1/internal/credits/balanceX-Oper-KeyAI creditsauthoring-v2, micro-learninglive
POST/v1/internal/credits/debitX-Oper-KeyAI creditsauthoring-v2, micro-learninglive
POST/v1/internal/credits/grantX-Oper-KeyAI creditsnone founddead
POST/v1/internal/credits/refundX-Oper-KeyAI creditsauthoring-v2, micro-learninglive
POST/v1/internal/groups/batchX-Oper-KeyGroups for fan-outassignment-servicelive
POST/v1/internal/notification-preferences/batchX-Oper-KeyNotification preferencesassignment-servicelive
GET/v1/internal/tenants/slug/{slug}X-Oper-KeyTenant lookupnone founddead
GET/v1/internal/tenants/{tenant_id}X-Oper-KeyTenant lookupauthoring-v2live
GET/v1/internal/tenants/{tenant_id}/adminsX-Oper-KeyTenant adminsnotification-workerlive
GET/v1/internal/tenants/{tenant_id}/directoryX-Oper-KeyEmployee directory (internal)micro-learninglive
GET/v1/internal/tenants/{tenant_id}/groups/{group_id}/membersX-Oper-KeyGroups for fan-outassignment-servicelive
GET/v1/internal/tenants/{tenant_id}/usersX-Oper-KeyEmployee directory (internal)AS, ML, feedlive
GET/v1/internal/tenants/{tenant_id}/usersX-Oper-KeyEmployee directory (internal) — duplicateunreachable — shadoweddead
GET/v1/internal/tenants/{tenant_id}/users/registration-seriesX-Oper-KeyRegistration seriesmicro-learninglive
POST/v1/internal/users/profiles/batchX-Oper-KeyProfile batchassignment, authoring-v2, feed, micro-learninglive
POST/v1/internal/validate-tokenX-Oper-KeyToken validationassignment, authoring-v2, notification-worker, libs/authlive
GET/v1/mobile/configpublicApp-update gatemobile applive
GET/v1/mobile/locationsemployee JWTWork-location pickermobile applive
POST/v1/mobile/logoutemployee JWTEmployee OTP sign-inmobile applive
GET/v1/mobile/meemployee JWTMobile identity & profilemobile applive
PUT/v1/mobile/meemployee JWTMobile identity & profilemobile applive
GET/v1/mobile/me/deletion-requestemployee JWTSelf-service deletionmobile applive
POST/v1/mobile/me/deletion-requestemployee JWTSelf-service deletionmobile applive
GET/v1/mobile/notification/preferencesemployee JWTNotification preferencesnone foundsuspect
PUT/v1/mobile/notification/preferencesemployee JWTNotification preferencesnone foundsuspect
POST/v1/mobile/sessions/otp/startpublic (OTP start)Employee OTP sign-inmobile applive
POST/v1/mobile/sessions/otp/verifypublic (OTP verify)Employee OTP sign-inmobile applive
POST/v1/oper-admin/authoring/socket-tokenstaff JWTAuthoring proxynone foundsuspect
PUT/v1/oper-admin/billing/plans/{plan_key}staff JWT or X-Oper-KeyStaff billing opsstaff consolelive
PATCH/v1/oper-admin/invoices/{invoice_id}staff JWT or X-Oper-KeyStaff billing opsstaff consolelive
POST/v1/oper-admin/logoutstaff JWTStaff sign-instaff consolelive
GET/v1/oper-admin/mestaff JWTStaff sign-instaff consolelive
GET/v1/oper-admin/modules/{module_id}/translationsstaff JWTStaff module lookupstaff consolelive
GET/v1/oper-admin/modules/{root_id}/lateststaff JWTStaff module lookupstaff consolelive
POST/v1/oper-admin/sessionpublic (password + TOTP)Staff sign-instaff consolelive
POST/v1/oper-admin/tenantsstaff JWT or X-Oper-KeyTenant lifecyclestaff consolelive
DELETE/v1/oper-admin/tenants/{tenant_id}staff JWT or X-Oper-KeyTenant lifecyclestaff consolelive
GET/v1/oper-admin/tenants/{tenant_id}staff JWTTenant 360staff consolelive
PATCH/v1/oper-admin/tenants/{tenant_id}staff JWT or X-Oper-KeyTenant lifecyclestaff consolelive
GET/v1/oper-admin/tenants/{tenant_id}/billingstaff JWT or X-Oper-KeyStaff billing opsstaff consolelive
PUT/v1/oper-admin/tenants/{tenant_id}/billingstaff JWT or X-Oper-KeyStaff billing opsstaff consolelive
POST/v1/oper-admin/tenants/{tenant_id}/billing/enterprisestaff JWT or X-Oper-KeyStaff billing opsstaff consolelive
GET/v1/oper-admin/tenants/{tenant_id}/creditsstaff JWTTenant 360staff consolelive
POST/v1/oper-admin/tenants/{tenant_id}/invoicesstaff JWT or X-Oper-KeyStaff billing opsstaff consolelive
GET/v1/oper-admin/tenants/{tenant_id}/pipelinestaff JWTTenant 360staff consolelive
GET/v1/oper-admin/tenants/{tenant_id}/pipeline/runs/{run_id}staff JWTTenant 360staff consolelive
POST/v1/oper-admin/tenants/{tenant_id}/statusstaff JWT or X-Oper-KeyTenant lifecyclestaff consolelive
GET/v1/oper-admin/tenants/{tenant_id}/usagestaff JWTTenant 360staff consolelive
GET/v1/oper-admin/tenants/{tenant_id}/usersstaff JWTTenant 360staff consolelive
POST/v1/oper-admin/token/refreshstaff session cookieStaff sign-instaff consolelive
POST/v1/oper-admin/totp/enrollstaff JWTStaff sign-instaff consolelive
GET/v1/oper-admin/totp/statusstaff JWTStaff sign-instaff consolelive
POST/v1/oper-admin/totp/verifystaff JWTStaff sign-instaff consolelive
POST/v1/public/admin/registerpublicCompany registration (deprecated alias)none foundsuspect
POST/v1/public/admin/resend-otppublicCompany registration (deprecated alias)none foundsuspect
POST/v1/public/admin/set-passwordpublicCompany registration (deprecated alias)none foundsuspect
POST/v1/public/admin/verify-otppublicCompany registration (deprecated alias)none foundsuspect

Three live routes the scan misses

The scanner resolves neither empty-string paths (@router.get(""), which inherit the router prefix whole) nor @router.api_route, so these three are absent from the JSON above and are listed separately to keep that table row-for-row with the contract. One of them is the single most-used entry point on the staff console.

MethodPathAuthFeatureCallersVerdict
GET/v1/internal/app-versionsX-Oper-KeyApp-update gateoperators (X-Oper-Key)live
GET/v1/oper-admin/tenantsstaff JWTTenant directorystaff consolelive
GET POST PUT PATCH DELETE/v1/oper-admin/authoring/{path:path}staff JWTAuthoring proxy (catch-all)staff consolelive

Evidence: app/routes/internal_app_versions.py:133 (operator read of the current floors, paired with the PUT above and named in docs/fe-app-update-gate.md:157) · app/routes/oper_backoffice.py:51 (the staff tenant directory, docs/fe-backoffice.md:123, :195) · app/routes/oper_authoring.py:369 (the back-office authoring catch-all, docs/fe-backoffice.md:130, :153-190).

5. Async contracts

All identity traffic rides one stream, AUTH (auth.>, users.*, tenants.*), which this service creates at boot (nats/init.sh:19-31, ConfigMap k8s/nats-screams-cm.yaml:24). The TENANTS and USERS stream definitions still sitting in that ConfigMap are never created — the init script says so in as many words (nats/init.sh:32). The otp-worker mounts this service's init script rather than shipping its own (k8s/otp-worker-deployment.yaml:43).

Consumes

SubjectStreamDurablePublished byFeatureVerdict
auth.admin.otp_startedAUTHotp-worker-auth-admin-otp_startedthis service (app/routes/admin_login.py:320, app/routes/public_registration.py:157)Admin sign-in code / sign-up code by email or SMSlive
auth.user.otp_startedAUTHotp-worker-auth-user-otp_startedthis service (app/routes/mobile_login.py:86)Employee sign-in codelive
auth.user.invitationAUTHotp-worker-auth-user-invitationthis service (app/routes/users.py:227)Invitation email/SMS for a new personlive
auth.admin.alertAUTHotp-worker-auth-admin-alertnotification-worker (app/services/admin_notification_service.py:206, subject app/core/config.py:155)Email mirror of an admin inbox notificationlive
auth.admin.enterprise_welcomeAUTHotp-worker-auth-admin-enterprise_welcomethis service (app/routes/admin_billing.py:199; subject app/services/entitlements.py:226)Welcome email when staff put a tenant on an Enterprise contractlive
auth.admin.onboarding.enrich_requestedAUTHonboarding-enrichment-workerthis service (app/routes/admin_onboarding.py:49)AI pre-fill of the onboarding wizardlive
users.*AUTHusers_auth_groups_resolverthis service (app/routes/users.py:222, :234, :249)Keep dynamic-group membership correct when people are hired, changed or removedlive

The users.* consumer reacts to five of the subjects it receives (created, updated, enabled, disabled, deleted) and acks the rest (app/services/groups_events_subscriber.py:36-47). Its durable is a pull consumer with max_deliver=4; anything that ends up undelivered is picked up by the dynamic-groups reconciler. The five otp-worker durables are derived from the subject name at subscribe time (app/otp_worker/processor.py:122), so the names above are exact, not templates.

Publishes

SubjectConsumed byFeatureVerdict
auth.admin.otp_startedotp-worker (this repo)Admin sign-in / sign-up codelive
auth.user.otp_startedotp-workerEmployee sign-in codelive
auth.user.invitationotp-workerInvitation linklive
auth.admin.enterprise_welcomeotp-workerEnterprise welcome emaillive
auth.admin.onboarding.enrich_requestedonboarding-enrichment-workerOnboarding AI pre-filllive
auth.admin.onboarding.enrichednobody“Enrichment finished” announcementdead
auth.admin_login_successnobodyAdmin sign-in audit eventdead
auth.admin_logoutnobodyAdmin sign-out audit eventdead
auth.employee_login_successnobodyEmployee sign-in audit eventdead
auth.employee_logoutnobodyEmployee sign-out audit eventdead
auth.otp_failednobodyotp-worker dead-letter record for an undeliverable codedead
users.createdthis service's groups resolver (users_auth_groups_resolver)Dynamic-group membershiplive
users.updatedsameDynamic-group membershiplive
users.deletedsameDynamic-group membershiplive
tenants.creatednobodyNew company announcementdead
tenants.updatednobodyCompany details / settings changeddead
tenants.deletednobodyCompany removeddead
groups.updatednobodyGroup rule changeddead
training.notifications.ten.*.admin.*notification-worker durable notification_worker_admin (filter training.notifications.ten.*.admin.>)Admin inbox: “a learner joined”, “the export you asked for is ready”live
authoring.usage.eventauthoring-service-v2 usage_consumerCost ledger entry for the onboarding LLM calllive

Evidence, publishers: app/routes/admin_login.py:320, :565, :861 · app/routes/mobile_login.py:86, :100, :108 · app/routes/users.py:222, :227, :234, :249 · app/routes/admin.py:67, :197, :282 · app/routes/admin_company.py:134 · app/routes/admin_onboarding.py:49, :56 · app/routes/admin_billing.py:199 · app/routes/public_registration.py:157 · app/routes/groups.py:851 · app/routes/mobile_profile.py:218 · app/services/gdpr_export.py:350 · app/otp_worker/processor.py:389 (DLQ) · app/onboarding_enrichment/processor.py:364. Consumers: notification-worker/app/core/config.py:115 (admin envelopes), authoring-service-v2/app/consumers/register.py:68 (usage), app/services/groups_events_subscriber.py:143 (users.*). The two training.notifications.ten.*.admin.* rows in the scan output are one f-string subject each — the literal subject is training.notifications.ten.<tenant_id>.admin.<type>, which the notification-worker filter matches token-for-token. users.* also reaches feed-service's event mapper, but feed consumes nothing at all (ARCHITECTURE.md §4.1(5)), so that edge does not make anything live.

Background jobs

JobScheduleWhat it doesVerdict
otp-worker (app/otp_worker/main.py, Deployment otp-worker)always on; 5 push durablesTurns the five auth.* events into real emails (Mailgun) and SMS (Twilio), with in-band retry and a heartbeat so JetStream does not redeliver mid-send. The link in the message is answered by the Cloudflare worker link-otp-worker.live
onboarding-enrichment-worker (app/onboarding_enrichment/main.py, own Deployment)always on; 1 durable, max_deliver=4Fetches the tenant's website, asks OpenRouter for an industry/size/role proposal, POSTs the result back over HTTP (edge H27) and files a cost-ledger event. This is the only place in the identity stack that calls an LLM.live
_heartbeat_loop (app/onboarding_enrichment/processor.py:188)per in-flight jobMarks the NATS message in-progress while a slow LLM call runs, so a 90s ack-wait does not cause a duplicate enrichment.live
_shutdown handlers (app/otp_worker/main.py:38, app/onboarding_enrichment/main.py:44)on SIGTERMSignal handlers, not jobs: they drain the consumer and close NATS so a rollout does not abandon an in-flight send. Scanned as background tasks because they are create_tasked.live
_consume_loop — dynamic-groups event subscriber (app/services/groups_events_subscriber.py:196)always on, inside the API podPull-consumes users.* and re-resolves every dynamic group the change could affect. The blocking DB work runs on a worker thread, because this pod also serves JWKS and token validation for the whole platform.live
dynamic-groups reconciler (app/services/reconcilers.py:294)30s after boot, then every DYNAMIC_GROUPS_RECONCILE_HOURS (default 6)Re-resolves every dynamic group in every tenant, to correct anything the event path lost to a DLQ or a restart. Single-flight behind a Postgres advisory lock.live
billing-seats reconciler (app/services/reconcilers.py:300)same pattern, BILLING_SEATS_RECONCILE_HOURS (default 6)Refreshes each tenant's monthly-active-learner overage between Stripe invoices, so the figure an admin sees is never a day stale and a missed webhook costs one interval, not a cycle.live
entitlements reconciler (app/services/reconcilers.py:306)same pattern, hourly by defaultOwns the two transitions nothing else triggers: ending a trial when its date arrives, and granting each later month of an Enterprise contract's credits.live
GDPR export CronJob users-auth-process-gdpr-exports (k8s/cron-process-gdpr-exports.yaml)0 2 * * * UTC, concurrencyPolicy: Forbid, 1h deadlineBuilds each queued subject-access bundle by fanning out to five services, stores it, and notifies the admins. Deliberately left as a CronJob rather than an in-pod loop: it is a heavy off-peak batch with a legal deadline (app/services/reconcilers.py:35-38).live

All three reconcilers replaced earlier k8s CronJobs and are started from the app lifespan (app/main.py:144-146); the groups subscriber at app/main.py:135. Every start is wrapped so a bring-up failure cannot block the API, which also means a silently unstarted loop looks identical to a healthy one in the logs.

6. Data it owns

Postgres database users_auth, 31 alembic revisions, and—unusually for this platform—no second writer: every other service reads this data over HTTP (ARCHITECTURE.md §3.4). The Postgres and Redis instances themselves are platform-shared despite their auth-scoped names, and this service's k8s manifests are what create them.

TableWhat it holdsWritten by
tenantsOne row per customer company: name, slug, website, industry, size, logo, brand colour, status, onboarding progress (JSONB), Twenty CRM ids, meeting-booked flagthis service only (staff tenant ops, company settings, onboarding)
usersEvery person: employees, tenant admins (with admin_role OWNER/ADMIN/EDUCATOR), password hash, TOTP secret, phone/email, job title, work location, profile, soft-delete markerthis service only
staff_usersOper's own back-office identities — deliberately not rows in users, because staff belong to no tenantthis service only (plus the bootstrap Job)
otp_challengesLive one-time-code challenges with attempt counters, for every sign-in and sign-up flowthis service only
qr_loginsQR sign-in attempts (migration 001). No writer anywhere — only the GDPR export still reads itnobody
rolesThe tenant's job titles, which group rules targetthis service only
locationsStores, sites and offices: address, coordinates, statusthis service only
groupsStatic and dynamic groups, including the membership rule and statusthis service only
group_membersResolved membership, tagged manual or rule-sourced. employee_email became nullable in 020 so phone-only employees can be membersthis service only (routes + resolver + NATS subscriber + reconciler)
notification_preferencesPer-user, per-category push/email opt-ins that every notification sender checksthis service only
billing_plansThe plan catalogue: price, MAU allowance, Stripe price idsthis service only (staff billing ops, scripts/bootstrap_stripe_catalog.py)
tenant_billingEach tenant's plan, trial window, Enterprise contract, Stripe customer/subscription, seat and MAU countersthis service only (admin billing, staff ops, Stripe webhook, reconcilers)
billing_invoicesInvoices, both Stripe-issued and staff-created manual onesthis service only
billing_eventsRaw Stripe webhook deliveries, for idempotency and auditthis service only
credit_balancesCurrent AI-credit balance per tenant — the number the authoring run gate readsthis service only (admin top-up, internal debit/refund/grant, entitlements sweep)
credit_ledgerAppend-only credit movements with the reference that caused themthis service only
app_versionsPer-platform minimum supported build, recommended build, store URL and gate messagethis service only (operator endpoint)
app_version_eventsAudit of every floor change, with who made itthis service only
gdpr_export_jobsQueued and completed subject-access exports, their scheduled window and the stored bundlethis service only (admin routes + GDPR CronJob)
account_deletion_requestsLearner-filed deletion requests with reason and resolutionthis service only
user_audit_logsWho changed which person, and howthis service only
tenant_audit_logsCompany-level admin actions. tenant_id is ON DELETE CASCADE, which is why the hard-delete audit row never survives (bug-hunt #9)this service only

No table here is written by another service, and this service writes into no other database. The shared postgres-db-init-job.yaml in this repo's k8s/ directory still creates three databases nothing references (certificates, progress, authoring_db — ARCHITECTURE.md §3.4).

7. Dependencies

flowchart LR
  ML["micro-learning"] --> UA
  AS["assignment-service"] --> UA
  AV2["authoring-v2"] --> UA
  NW["notification-worker"] --> UA
  FS["feed-service"] --> UA
  OEW["onboarding-enrichment-worker"] --> UA
  UA["users-auth-service"] --> ML
  UA --> AS
  UA --> NW
  UA --> FS
  UA --> AV2
  UA --> EXT["Stripe · Twenty CRM · Sentry"]
  OTP["otp-worker"] --> SEND["Mailgun · Twilio"]
  UA -. "AUTH stream" .-> OTP
  UA -. "AUTH stream" .-> OEW

Inbound (all with X-Oper-Key): micro-learning for users, directory, preferences and the credit debit at publish (H11-12); assignment-service for groups, members, profiles and token validation (H15); authoring-v2 for tenant info, profile batch and the credit gate (H4); notification-worker for tenant admins and preferences (H25); feed-service for users and profiles; the onboarding worker for its result POST (H27). Outbound: micro-learning analytics, billing and training stats (H19, also on the GDPR cron and the mobile /me path); assignment-service user assignments and activity (H20); notification-worker active devices (H21); authoring-v2 through the back-office proxy (H22) and the pipeline reads; and the GDPR export fan-out to four services (H23). Note the prefix trap: authoring-v2 exposes /internal/v1/* while everyone else uses /v1/internal/* (ARCHITECTURE.md §3.2).

8. Dead-code verdicts

Every entry point with no in-repo caller. Deleting is a separate decision — see the hub roll-up.

Entry pointKindVerdictEvidence
GET /routesuspectStatus stub. Both k8s probes point at /healthz (k8s/users-auth-deployment.yaml:189, :202); no in-repo caller of /. It sits on the public ingress, so an external uptime check may well hit it — unknowable from this repo.
POST /debug/observabilityroutedeadBoth halves: no caller anywhere in the repo, and the handler refuses unless settings.debug (app/main.py:243) while every deployed workload sets DEBUG="false" (k8s/users-auth-deployment.yaml:169-170, k8s/cron-process-gdpr-exports.yaml:57-58). Unreachable as deployed.
POST /v1/auth/tokenroutedeadBoth halves: gated behind X-Oper-Key (app/routes/auth.py:117), so no browser or app can reach it; and no machine caller exists — the handler's own docstring says “No first-party client calls it” (app/routes/auth.py:128), confirmed by grep (only tests/test_auth.py and scripts/smoke_test.py). bug-hunt #1 rejected the claimed bypass and recommended deleting the endpoint.
POST /v1/internal/auth/tokenroutedeadInternal-only (X-Oper-Key) mint of an internal.service token. Repo-wide grep for internal/auth/token finds only tests/test_auth.py:119, scripts/smoke_test.py:322 and an archived summary; no service calls it. Services authenticate to each other with the shared key directly.
POST /v1/internal/credits/grantroutedeadInternal-only, no caller: the only clients of the internal credit API build /v1/internal/credits/{op} with op in debit/refund (authoring-service-v2/app/core/http.py:599, :645, :667; micro-learning-service-v2/app/clients/credits_client.py:66, :93, :113). Grants that do happen are made in-process by the entitlements reconciler, not over HTTP. Only New-Design/SDD-credit-ledger.md:60 mentions the route.
GET /v1/internal/tenants/slug/{slug}routedeadInternal-only, no caller: repo-wide grep for tenants/slug finds the handler (app/routes/internal.py:284), scripts/smoke_test.py:175, :196 and one archived summary. Every service resolves tenants by id via the sibling route.
GET /v1/internal/tenants/{tenant_id}/users (app/routes/users.py:1417)route (duplicate)deadThe same method+path is registered twice. internal_router from app/routes/internal.py is included first (app/main.py:282), so get_all_tenant_users (internal.py:331) wins every request and list_employees_internal — a richer, filterable, paginated handler — is unreachable. Its query parameters silently do nothing for callers (assignment-service, micro-learning, feed-service).
POST /v1/admin/onboarding/meeting-bookedroutesuspectAdmin-facing, and the admin web app is not in this repo. Searched every users-auth-service/docs/*.md and New-Design/*.md for meeting-booked/meeting_booked: no integration doc names it (the onboarding guide documents the wizard's optional book_meeting step instead). Only tests/ calls it.
POST /v1/admin/{tenant_id}/users/bulk/statusroutesuspectNo in-repo caller and no FE doc: docs/fe-multi-admin-team.md documents the single-user routes only, and grep for users/bulk/ across all docs finds nothing. Only tests/test_users.py.
POST /v1/admin/{tenant_id}/users/bulk/roleroutesuspectSame search as above — tests only.
POST /v1/admin/{tenant_id}/users/bulk/storeroutesuspectSame search as above — tests only.
POST /v1/admin/{tenant_id}/users/bulk/deleteroutesuspectSame search as above — tests only. All four also share the malformed-UUID 500 (bug-hunt #18).
GET /v1/mobile/notification/preferencesroutesuspectMobile-facing; the app is not in this repo. Searched users-auth-service/docs/*.md, New-Design/*.md and micro-learning-service-v2/docs/*.md for notification/preferences: no mobile integration doc names the route. Only scripts/smoke_test.py:431 exercises it.
PUT /v1/mobile/notification/preferencesroutesuspectSame search as above.
POST /v1/oper-admin/authoring/socket-tokenroutesuspectStaff-console-facing; that console is not in this repo. Its contract doc still tells the console the websocket is not proxied and to keep polling (docs/fe-backoffice.md:186-190), so no documented client mints a socket token yet. Only tests/test_oper_authoring_proxy.py:465. The endpoint is deliberate and newer than the doc — not dead, just unadopted.
POST /v1/public/admin/registerroute (deprecated alias)suspectThe same handler as /v1/admin/registration/register, mounted again at a hidden prefix “kept for backward compatibility; remove once clients migrate” (app/main.py:305-306). The FE was told to cut over (docs/admin-frontend-integration.md:36-44); whether it has is unknowable here. Only scripts/smoke_public_admin_registration.py:64 still uses this prefix.
POST /v1/public/admin/verify-otproute (deprecated alias)suspectAs above; scripts/smoke_public_admin_registration.py:82.
POST /v1/public/admin/set-passwordroute (deprecated alias)suspectAs above; scripts/smoke_public_admin_registration.py:96.
POST /v1/public/admin/resend-otproute (deprecated alias)suspectThe weakest of the four: resend-otp was added after the alias was deprecated, so it never had a client on this prefix (it is absent from the old-path column of docs/admin-frontend-integration.md:41) and nothing in the repo calls it. Reachable in principle, hence suspect rather than dead.
auth.admin.onboarding.enrichedsubjectdeadPublished at app/routes/admin_onboarding.py:56. No consumer filter in the platform matches it: repo-wide grep finds the publisher, the constant (:44) and New-Design/SDD-admin-notification-centre.md:125 (a plan, not an implementation). notification-worker's admin consumer listens on training.notifications.ten.*.admin.>, not on auth.>.
auth.admin_login_successsubjectdeadPublished at app/routes/admin_login.py:565. Grep finds no subscriber anywhere — only the publisher, tests/test_admin.py:794 and an archived summary. Retained in the AUTH stream for 7 days and then discarded.
auth.admin_logoutsubjectdeadPublished at app/routes/admin_login.py:861; no subscriber in the repo.
auth.employee_login_successsubjectdeadPublished at app/routes/mobile_login.py:100; no subscriber (only tests/test_mobile.py:170 and New-Design/SDD-admin-notification-centre.md:102, a plan).
auth.employee_logoutsubjectdeadPublished at app/routes/mobile_login.py:108; no subscriber.
auth.otp_failedsubjectdeadThe otp-worker's dead-letter subject (app/otp_worker/processor.py:389, configured at app/otp_worker/config.py:58). Nothing consumes or alerts on it, so a permanently failed OTP is recorded where no one looks — and the publish happens after the ack, so a publish failure loses the event outright (bug-hunt-reports/otp-worker.md #2).
tenants.createdsubjectdeadPublished at app/routes/admin.py:67. The AUTH stream captures tenants.*, but no consumer in any service filters on it (repo-wide grep: publishers, archived docs and tests only).
tenants.updatedsubjectdeadPublished from two places (app/routes/admin.py:197, app/routes/admin_company.py:134) so a company rename or settings change is announced twice over — to nobody.
tenants.deletedsubjectdeadPublished at app/routes/admin.py:282; no consumer. Combined with bug-hunt #9 (the audit row is cascade-deleted), a tenant hard-delete currently leaves no durable trace at all.
groups.updatedsubjectdeadPublished at app/routes/groups.py:851 when a group's rule changes. No consumer exists; membership convergence happens in-process instead, so nothing depends on it.
TENANTS and USERS stream definitionsconfigdeadk8s/nats-screams-cm.yaml:7-50 still carries both JSONs, but the init script creates only AUTH and says so: “Legacy streams TENANTS/USERS are no longer created” (nats/init.sh:32, ConfigMap copy k8s/users-auth-nats-init-script.yaml:36). They also overlap AUTH's subjects, so creating them would be rejected.
qr_logins tabledatadeadCreated by migration 001_init_users_auth_schema. Repo-wide grep finds no INSERT INTO qr_logins and no write path of any kind; the only reference is the GDPR export reading it (app/services/gdpr_export.py:123-127), so every export carries an always-empty section.
app/routes/users.py:1417 list_employees_internalfunctiondeadUnreachable for the routing reason above; with it, its status/user_type/date filters and pagination are unreachable code.

Two report findings are now stale rather than open, which is worth recording so nobody re-opens them: bug-hunt #14 (“NATS groups subscriber runs sync psycopg2 on the event loop”) no longer matches the code — the blocking work is on a worker thread and the docstring explains why (app/services/groups_events_subscriber.py:50-58); and the report's premise that the dynamic-groups sweep is a k8s CronJob (#10, #17) is superseded by the in-process reconcilers (app/services/reconcilers.py:1-38), though the SAVEPOINT fix it asked for is in place. Everything else in that report is still open and is linked from the feature it belongs to.

9. Sources