djangoplay-web / Apps / DjangoPlay — users App
DocsDjangoPlay WebAppsDjangoPlay — users App

DjangoPlay — users App

users (verbose name "Users", AppConfig.name = "users") is DjangoPlay's identity-only app. The custom AUTH_USER_MODEL (users.UserIdentity) lives here, and — as the module docstring in users/models/_...

22 min readApplies to v1.2.2
On this page ▾
  1. 1. Summary
  2. 2. Architecture
  3. 2.1 What UserIdentity actually owns (verified from the model)
  4. 2.2 Request-flow architecture — thin views/adapters, services own the rules
  5. 2.3 Two independent token systems, both owned by users
  6. 3. Implementation Details
  7. 4. Functionality — Identity Flows
  8. 4.1 Manual signup (email + password)
  9. 4.2 Allauth-driven signup (email confirmation flow / social, shared code path)
  10. 4.3 Email verification
  11. 4.4 Login (three entry points, one shared rule engine)
  12. 4.5 SSO / social login
  13. 4.6 Password reset
  14. 4.7 Session-based re-authentication (modal re-login without full page reload)
  15. 5. API Surface
  16. 5.1 API (views/api/v1/)
  17. 5.2 UI (views/ui/)
  18. 6. Security
  19. 6.1 A hardcoded backdoor identity: redstar / redstar@djangoplay.org
  20. 6.2 Hardcoded JWT signing key (shared platform-wide finding, directly relevant here)
  21. 6.3 remember_me lifetime looks inverted
  22. 6.4 Username enumeration via login error messages
  23. 6.5 What's genuinely solid here
  24. 7. Cross-App Integration Points
  25. 8. Known Gaps / Things Worth Confirming With the Team
  26. 9. Quick Reference — Adding a New Login/Signup Entry Point

Doc generated by direct inspection of every file in webapp/users/, plus its declared cross-app touchpoints in teamcentral, mailer, policyengine-adjacent utilities.admin.app_registry, paystream.security.infra, paystream.app_settings.authx, paystream.app_settings.jwt, and genericissuetracker.

1. Summary

"This app owns IDENTITY ONLY. DO NOT add: HR models, Team/Department models, Support/Bug models, business-domain relations."

Everything HR/profile-shaped (employment status, department, role, address, contact info) was deliberately split out into teamcentral.EmploymentProfile / teamcentral.MemberProfile — a real migration exists for this (0002_migrate_employee_to_useridentity.py → 0003_drop_users_employee.py), meaning users.Employee used to hold all of this and was split apart. UserIdentity exposes employment_profile / member_profile as lazy read-only property shortcuts, but never queries or owns that data.

Important correction to the stated premise: despite authx-identity being a pinned dependency (pyproject.toml) and having a full settings module (paystream/app_settings/authx.py — base URL, RS256 JWT config, service token, JWKS public key, issuer/audience, timeouts, CORS), no code anywhere in webapp/ actually calls out to AuthX at request time. A repo-wide search for authx/AUTHX turns up exactly 4 files, and all four are settings/secrets plumbing (paystream/app_settings/authx.py, paystream/settings/base.py star-importing it, paystream/security/encrypt_env.py, paystream/security/decrypt_env.py — both just listing AUTHX_* as encryptable/decryptable env keys). There is no AuthX client class, no custom DRF authentication backend built on it, and DRF's actual DEFAULT_AUTHENTICATION_CLASSES (paystream/app_settings/rest_framework.py) is rest_framework_simplejwt.authentication.JWTAuthentication + SessionAuthentication — nothing AuthX-related. On this branch, users is self-contained identity: django-allauth + djangorestframework-simplejwt, not AuthX. The AuthX settings module reads as a service that's configured/scaffolded for a future or parallel deployment (an external identity microservice, given the separate AUTHX_DATABASE_URL), not one that's wired into this app's login/session/JWT paths yet. Worth confirming directly with whoever owns the AuthX rollout whether that integration is mid-migration, paused, or intentionally optional.


2. Architecture

2.1 What UserIdentity actually owns (verified from the model)

plaintext
UserIdentity (AbstractUser + TimeStampedModel + AuditFieldsModel)
    │
    ├── Login credentials: username, password (via AbstractUser), last_login
    ├── SSO linkage: sso_provider (GOOGLE/APPLE/EMAIL/MICROSOFT), sso_id (unique)
    ├── Security flags: is_active, is_verified, is_staff, is_superuser
    ├── Unsubscribe state: is_unsubscribed, unsubscribed_at
    ├── Soft-delete: deleted_at, deleted_by (via AuditFieldsModel), is_active
    ├── History: simple_history HistoricalRecords()
    │
    └── Lazy shortcuts (NOT owned data, just property access into other apps):
          .employment_profile → teamcentral.EmploymentProfile (reverse O2O, `employmentprofile`)
          .member_profile     → teamcentral.MemberProfile (reverse O2O, `memberprofile`)
          .effective_timezone → falls back through employment → member → "Asia/Kolkata"

Three custom managers: objects (UserIdentityManager, used for creation), active_objects (ActiveManager — excludes soft-deleted), all_objects (plain Manager, includes soft-deleted). Login/lookup services deliberately choose which manager to query depending on whether soft-deleted users are relevant to that check (see §4).

2.2 Request-flow architecture — thin views/adapters, services own the rules

This app follows the platform-wide "service layer owns logic" convention strictly. Every entry point (allauth adapter hook, DRF view, plain Django view) is a thin wrapper delegating to one of 8 services in users/services/:

plaintext
Entry points                          Service (owns the actual rule)
─────────────────────────────────────────────────────────────────────────
ConsoleLoginView (web)          ┐
CustomAccountAdapter.pre_login  ┼──▶ UnifiedLoginService.validate_user()
CustomSocialAccountAdapter      ┘    (identity_login_policy_service.py)
.authentication_successful

CustomAccountAdapter.save_user       ┐
                                      ┼──▶ SignupFlowService
CustomSocialAccountAdapter           ┘    (identity_signup_flow_service.py)
.pre_social_login (new-user path)

CustomAccountAdapter.confirm_email  ──▶ SignupFlowService.handle_email_confirmation
UnifiedEmailVerifyView              ──▶ SignupTokenManagerService (identity_verification_token_service.py)

CustomSocialAccountAdapter          ──▶ SSOOnboardingService (identity_sso_onboarding_service.py)
.pre_social_login (existing-user linking / new-user onboarding)

CustomPasswordResetView             ──▶ PasswordResetService (identity_password_reset_service.py)
CustomPasswordResetConfirmView      ──▶ PasswordResetTokenManagerService (identity_password_reset_token_service.py)

(cross-app callers, e.g. IdentityQueryService.get_identity_snapshot)
                                     ──▶ IdentityQueryService / IdentityStateService
                                          (identity_query_service.py / identity_state_service.py)

paystream.integrations.issuetracker ──▶ DjangoPlayIssueTrackerIdentityResolver
                                          (issuetracker_identity_resolver.py)

Adapters (users/adapters/) are explicitly documented in their own docstrings as "thin, service-driven" — CustomAccountAdapter and CustomSocialAccountAdapter contain almost no business rules themselves; they translate between django-allauth's hook contract and the services above. BaseAdapter is a shared mixin providing get_support_context(), get_redirect_resolver(), get_login_validator(), send_template_email() (routes through mailer.engine.engine.EmailEngine) — used by both account and social adapters.

2.3 Two independent token systems, both owned by users

users issues and validates two separate kinds of opaque tokens, both modeled the same way (short-lived, SchemaFrozen-adjacent TimeStampedModel + AuditFieldsModel rows with a unique indexed token field, expires_at, and history = HistoricalRecords()):

Token type Model Prefix Lifetime Manager
Email verification SignUpRequest vrf_ (60 hex chars) settings.LINK_EXPIRY_DAYS["email_verification"] SignupTokenManagerService
Password reset PasswordResetRequest pwd_ (64 hex chars) settings.LINK_EXPIRY_DAYS["password_reset"] PasswordResetTokenManagerService

Both enforce "only one active token per user" — SignUpRequest.clean() raises ValidationError if an active, non-expired request already exists (and the manager service reuses the existing one rather than creating a duplicate — see SignupTokenManagerService.create_for_user, step 1: "Reuse existing ACTIVE signup request (DRY)"). PasswordResetTokenManagerService.create_for_user takes the opposite approach: it eagerly invalidates all prior active tokens (deleted_at = timezone.now()) before issuing a new one, rather than reusing.


3. Implementation Details

  • Custom user model: AUTH_USER_MODEL = "users.UserIdentity" (set in paystream/app_settings/core.py), subclasses Django's AbstractUser — so it retains username, password, first_name, last_name, is_staff, date_joined, last_login, and the standard groups/user_permissions M2M (remapped here to related_name="user_identity_groups" / "user_identity_permissions" to avoid clashing with Django's default auth.User, which still exists in INSTALLED_APPS indirectly via django.contrib.auth).
  • Migrations: only 3 — 0001_initial, 0002_migrate_employee_to_useridentity (data migration), 0003_drop_users_employee (schema migration). This confirms the "Employee → UserIdentity" split described in the model docstrings actually happened as a real, executed migration, not just aspirational documentation.
  • History: django-simple-history on all three models (UserIdentity, SignUpRequest, PasswordResetRequest) — full field-level history table per model, independent of (and in addition to) the platform-wide audit app's diff-based AuditEvent log.
  • Audit registration: UsersConfig.ready() registers users.UserIdentity, users.SignUpRequest, users.PasswordResetRequest into audit.lifecycle.registry.AUDIT_TRACKED_MODELS — so create/update events on all three flow into the platform audit trail automatically (see the audit app doc, §2.2).
  • API surface layout mirrors the platform-wide DRF convention seen elsewhere (crud / read/{list,detail,history} / ui) — see §5.
  • Serializers: split into serializers/base/ (shared field logic) and serializers/v1/{read,write}/ per model — read and write serializers are intentionally separate classes, not one serializer with read_only flags sprinkled in.
  • Forms: forms/admin/ (Django-admin-facing forms for UserIdentity, SignUpRequest, PasswordResetRequest) and forms/frontend/password_reset.py (the actual public-facing password reset form).

4. Functionality — Identity Flows

4.1 Manual signup (email + password)

SignupFlowService.handle_manual_signup (called from ManualSignupView), in order:

  1. Runs signup abuse analysis via paystream.security.infra.signup_abuse_service.SignupAbuseService.analyze() — name/email/IP/user-agent heuristics, including a disposable-email-domain check (DisposableEmailService, backed by the checked-in configs/disposable_domains.txt allowlist — see the paystream doc's fraud-scoring table for the full signal breakdown) → action is one of allow / challenge / block / hard_block. block/hard_block reject the signup outright with a generic error message (no detail leaked to the client); challenge is logged but currently still allowed through.
  2. Enforces the identity invariant: one email = one UserIdentity (_assert_identity_not_exists), and separately checks username uniqueness (raises UsernameAlreadyTakenError if taken).
  3. Creates the UserIdentity row (is_active=True, is_verified=False, sso_provider="EMAIL").
  4. Immediately calls into teamcentral.services.MemberLifecycleService.create_member(...) to create the linked MemberProfile — i.e. even though users is schema-frozen to identity, the signup service itself is the orchestration point that creates the cross-app HR-side record. This is a deliberate, single well-known crossing point rather than an accidental leak.
  5. Assigns default role-based permissions via utilities.ui.dashboard.services.registry_permissions_service.assign_app_registry_permissions(user, default_role_code).
  6. Creates an allauth.account.models.EmailAddress row (verified=False, primary=True) — allauth's own verification bookkeeping is kept in sync alongside DjangoPlay's own SignUpRequest token.
  7. Issues a verification token via SignupTokenManagerService.create_for_user.
  8. Queues a welcome email (mailer.flows.member.signup.send_successful_signup_email_task, 30s countdown) via transaction.on_commit — so the Celery task is only enqueued if the DB transaction actually commits.

4.2 Allauth-driven signup (email confirmation flow / social, shared code path)

SignupFlowService.handle_allauth_signup is the allauth-glue counterpart — runs the same abuse analysis (with SSO/social flows treated more leniently: only hard_block stops the flow outright; challenge/block are logged but allowed), enforces the same one-email-one-identity invariant (only when user.pk is falsy, i.e. not yet saved — allauth is documented in the code comment as sometimes calling save_user twice), sets identity-only fields (email/first/last name, sso_provider), and syncs the EmailAddress row.

4.3 Email verification

UnifiedEmailVerifyView → SignupTokenManagerService.validate_token(token) (pure, stateless — checks prefix, existence, soft-delete, expiry — “login/session state is irrelevant” per its own docstring) → on success, consume_and_activate():

  • Soft-deletes all of the user's other SignUpRequest rows (bulk update(deleted_at=...), not per-row .save() — bypasses model save()/history hooks intentionally for this bulk cleanup).
  • Marks the allauth EmailAddress verified.
  • Sets user.is_verified = True.
  • Cross-app write: if the user has an EmploymentProfile, sets its employment_status to the EmploymentStatus row with code="ACTV". If a MemberProfile exists, sets its status to MemberStatus with code="ACTV". This is the one place in users that directly mutates teamcentral model state rather than just linking to it.

4.4 Login (three entry points, one shared rule engine)

UnifiedLoginService.validate_user(user) (identity_login_policy_service.py) is explicitly documented as the single source of truth, shared by ConsoleLoginView (web), ApiLoginView (API), CustomAccountAdapter.login(), and CustomSocialAccountAdapter.save_user(). Order of checks:

  1. user is None → USER_NOT_FOUND
  2. deleted_at is not None → ACCOUNT_DELETED
  3. not is_active → ACCOUNT_INACTIVE
  4. not is_verified → EMAIL_NOT_VERIFIED
  5. If an EmploymentProfile exists: its employment_status must exist, not be soft-deleted/inactive, and have code == "ACTV" — otherwise EMPLOYMENT_NOT_ACTIVE. (Members without an EmploymentProfile — i.e. SSO/external users — skip this check entirely, by design: "internal workforce only".)

EMAIL_NOT_VERIFIED specifically routes through mailer.engine.verification_guard.handle_unverified_email() instead of a generic error message — presumably re-sending or prompting for a fresh verification email rather than just failing.

JWT login (CustomTokenObtainPairView, /api/v1/auth/token/) is a separate code path that does not call UnifiedLoginService at all — it only goes through DRF SimpleJWT's own TokenObtainPairSerializer.validate() (which itself calls Django's authenticate(), honoring is_active via Django's default ModelBackend, but does not know about is_verified or EmploymentProfile status). Two behaviors worth flagging:

  • It accepts either username or email as the username field — if the value contains @, CustomTokenObtainSerializer.validate() looks up the real UserIdentity by email and swaps in the canonical username before delegating to the parent serializer.
  • A remember_me flag, if true, mints a fresh RefreshToken with REFRESH_TOKEN_LIFETIME_REMEMBER_ME (1 minute — see §6, this looks like an inverted default) instead of the standard REFRESH_TOKEN_LIFETIME (1 day).

4.5 SSO / social login

SSOOnboardingService.handle_pre_social_login (called from CustomSocialAccountAdapter.pre_social_login) tries three strategies in order, and stops at the first match:

  1. Link by existing sso_id + sso_provider — direct re-login for a previously-linked SSO account.
  2. Link by existing email — a user who originally signed up manually (or via a different SSO provider) using the same email gets their sso_id/sso_provider attached to their existing identity, plus assign_app_registry_permissions(user_identity, "SSO").
  3. Create brand-new UserIdentity + MemberProfile — username auto-derived from the email local-part with numeric suffixing on collision (_build_username_from_email), is_verified=True immediately (no email-verification step for SSO signups, consistent with allauth's send_confirmation_mail override which suppresses the confirmation email entirely for social signups), and is_superuser set based on a hardcoded email match (see §6 — Known Gaps).

An email missing from the provider's payload (sociallogin.account.extra_data) is treated as a hard failure with a redirect to a dedicated social_login_error route — DjangoPlay requires an email from every SSO provider; there's no "collect email manually after SSO" fallback flow.

4.6 Password reset

PasswordResetService.send_reset_link — resolves the user by email or username (respecting deleted_at if present on the model), requires is_active and is_verified (silently returns RESET_STATUS_NOT_FOUND for both — deliberately not distinguishing "doesn't exist" from "exists but unverified/inactive", to avoid leaking account existence at this specific step), checks is_unsubscribed separately (RESET_STATUS_UNSUBSCRIBED), then rate-limits via mailer.throttling.flow_throttle.allow_flow(flow="password_reset", ...) before delegating token creation to PasswordResetTokenManagerService and queuing mailer.flows.password_reset.send_password_reset_email_task.

4.7 Session-based re-authentication (modal re-login without full page reload)

SessionCheckView (GET) / SessionReLoginView (POST) — a pair of plain Django views (not DRF) used by frontend JS (session.js) to detect an expired session and re-authenticate inline without redirecting. SessionCheckView tracks true session age itself via a _login_timestamp value stamped into the session at login time (both ConsoleLoginView.form_valid and SessionReLoginView.post stamp it), specifically because SESSION_SAVE_EVERY_REQUEST=True would otherwise keep resetting Django's own session expiry clock on every poll. Both views manually implement CORS header logic (Access-Control-Allow-Origin echoed only if the request Origin matches something in CSRF_TRUSTED_ORIGINS) rather than relying on django-cors-headers — presumably because these are same-origin-but-cross-subdomain calls (main domain ↔ issues./docs. subdomains) that the global CORS config doesn't cover. A near-identical pair of endpoints is also mounted directly on the issues subdomain urlconf (IssueSessionCheckView, IssueReLoginView in paystream.integrations.issuetracker.views.ui.session — see the platform overview doc, §11) — worth checking whether that's a thin re-export of this same logic or a parallel reimplementation when documenting the issuetracker integration.


5. API Surface

Mounted at users/urls.py → included into the main urlconf; base path per README/urlconf conventions is /api/v1/... for the API side and un-prefixed for UI routes.

5.1 API (views/api/v1/)

Path (relative to auth/, crud/, read/, ui/) View Purpose
auth/token/ CustomTokenObtainPairView JWT login (username or email)
auth/token/refresh/ CustomTokenRefreshView Refresh access token
auth/token/verify/ RedocTokenVerifyView Stub — always returns {"valid": true} (excluded from schema, likely exists only so Swagger/ReDoc's built-in "verify" auth flow has something to call)
auth/csrf/ CSRFTokenView Issues a CSRF token for SPA/JS clients
auth/log/ AuthLogView Client-side JWT lifecycle logging sink (session_expired, re_authenticated events) — AllowAny, just logs server-side
crud/signup-requests/, crud/user-identities/ SignUpRequestViewSet, UserIdentityViewSet Full DRF DefaultRouter-registered CRUD viewsets
read/list/..., read/detail/<pk>/..., read/history/... List/Detail/History API views Separate read-only surface for signup-requests, password-reset-requests, user-identities — history endpoints presumably surface django-simple-history records
ui/user-identities/ UserIdentityAutocomplete Admin/select2 autocomplete endpoint

5.2 UI (views/ui/)

Path View Purpose
login/ ConsoleLoginView Main web login form (extends allauth's LoginView)
logout/ CustomLogoutView Logout
api-login/ ApiLoginView A separate login entry point from the console one — likely for JS/SPA-driven login that still wants Django session auth rather than JWT
auth/me/, auth/me/jwt/ SessionUserMeView, UserMeView "Who am I" endpoints, session- and JWT-flavored respectively
auth/session/check/, auth/session/relogin/ see §4.7
dashboard/ dashboard_view Post-login console landing page
password/reset/, password/reset/<token>/ CustomPasswordResetView, CustomPasswordResetConfirmView
signup/, manual-signup/ CustomSignupView, ManualSignupView Two distinct signup entry points — CustomSignupView (sso_onboarding.py) is presumably the allauth-driven form-based signup; ManualSignupView is the direct-service path described in §4.1
verify/ UnifiedEmailVerifyView
resend-verification/ function-based view
unsubscribe/<uidb64>/<token>/, unsubscribe/ UnsubscribeView Two variants — signed-link and manual-entry unsubscribe
stats/public/, stats/private/, stats/chart/embed/ PublicAPIStatsView, PersonalAPIStatsView, APIStatsChartEmbedView API usage stats views — largest file in the app (stats.py, 796 lines); has its own redstar-only IP-visibility carve-out (see §6)
license/file/ license_file_view Serves a license file — unrelated to identity, oddly located in this app
accounts/3rdparty/login/cancelled/ social_login_cancelled_view

6. Security

6.1 A hardcoded backdoor identity: redstar / redstar@djangoplay.org

This is the single most important thing to know about this app's security model. redstar@djangoplay.org is the default value of SUPERUSER_EMAIL (paystream/app_settings/common.py, overridable via ~/.dplay/.secrets) — the account created by create_superuser / dplay's bootstrap flow. But rather than being treated as "just a superuser" and gated through Django's normal is_superuser/permission system, the literal strings "redstar" / "redstar@djangoplay.org" are hardcoded and independently checked in at least 9 different files across 3 apps:

File What it bypasses
users/adapters/accounts/custom.py (pre_login, confirm_email) Skips UnifiedLoginService validation entirely; auto-marks email as verified without a token
users/adapters/accounts/social.py (authentication_successful) Skips UnifiedLoginService validation for social login
users/adapters/login/validation.py (LoginValidationHelper.enforce) Same bypass, third independent copy of the same check
users/services/identity_sso_onboarding_service.py Grants is_superuser=True automatically if a new SSO signup's email matches this string
users/views/ui/stats.py Client IP addresses are included in exported stats only when the requesting user is redstar
mailer/engine/engine.py (EmailEngine.send) All outgoing transactional email to this address is silently skipped
utilities/services/is_admin.py, utilities/admin/app_registry.py "redstar superuser bypasses everything" (admin permission registry)
paystream/custom_site/admin_console_views.py Multiple username != "redstar" gate checks on custom admin console views

Why this matters: this isn't one central "is this the seed superuser" check — it's the same string comparison duplicated independently across at least 4 different files in users alone (plus 3 more apps). That has two concrete risks: (1) if SUPERUSER_EMAIL/username is ever changed via ~/.dplay/.secrets (which the app explicitly supports), most of these hardcoded comparisons will silently stop matching except the ones reading from settings.SUPERUSER_EMAIL — but several compare the literal string, not the setting, so behavior would become inconsistent across features rather than uniformly updating; (2) if an attacker or a future SSO provider is ever tricked into presenting an account with the email redstar@djangoplay.org, identity_sso_onboarding_service.py's bypass would hand out is_superuser=True automatically on first login, with no additional check — that's a real privilege-escalation path if the app's own email-verification/ownership assumptions for that specific address are ever wrong (e.g. if an external OAuth provider doesn't itself guarantee verified-email-ownership for that literal string). Worth raising with the team as a design review item, not just a lint issue.

6.2 Hardcoded JWT signing key (shared platform-wide finding, directly relevant here)

As flagged in the platform overview: paystream/app_settings/jwt.py's SIMPLE_JWT["SIGNING_KEY"] is a plaintext value committed to source, HS256. This is the actual key CustomTokenObtainPairView (owned by this app) signs every access/refresh token with. Everything else this app touches for secrets (AUTHX_*, DJANGO_SECRET_KEY) goes through get_decrypted_value(); this one constant does not. Anyone with read access to this repository can forge valid JWTs for any user ID.

6.3 remember_me lifetime looks inverted

SIMPLE_JWT["REFRESH_TOKEN_LIFETIME_REMEMBER_ME"] = timedelta(minutes=1), applied by CustomTokenObtainPairView when the client explicitly passes remember_me: true. As written, checking "remember me" produces a refresh token that expires in 60 seconds — the opposite of what a "remember me" checkbox should do (normal, unchecked login gets the longer REFRESH_TOKEN_LIFETIME of 1 day). This reads as a swapped constant rather than intentional behavior; worth confirming with whoever owns this flow, since as-is it would make "remember me" log users out almost immediately.

6.4 Username enumeration via login error messages

ConsoleLoginView.form_invalid (web_login.py) returns four distinct, differently-worded messages depending on: account doesn't exist ("No account found with this email or username"), account inactive, unverified email, or (implicitly, the fallback case) wrong password. This lets an unauthenticated visitor distinguish "this email isn't registered" from "this email is registered but you got the password wrong" — a standard username/email enumeration weakness. This is a UX-vs-security tradeoff some teams accept deliberately (better error messages for real users); flagging it because it doesn't appear to be a documented, deliberate decision anywhere in the code comments.

6.5 What's genuinely solid here

  • UnifiedLoginService as one shared gate (aside from the JWT path and the redstar bypass) is a good pattern — it's a real single source of truth, not just documentation claiming to be one.
  • Signup abuse detection (SignupAbuseService.analyze) runs on both manual and SSO signup paths, with SSO deliberately more lenient — sensible, since SSO accounts are pre-vetted by the provider.
  • Rate limiting on password reset (mailer.throttling.flow_throttle.allow_flow) and on JWT token issuance (TokenThrottle, 50/hour) are both real and wired in, not just configured-but-unused.
  • Password-reset user resolution intentionally collapses "not found," "inactive," and "unverified" into the same generic response (RESET_STATUS_NOT_FOUND) — the opposite pattern from §6.4, and the correct one for this particular flow. It's an inconsistency across the two features worth reconciling, but the password-reset side got it right.
  • Open-redirect protection is real, not decorative: ConsoleLoginView.get_success_url and LoginRedirectHelper both validate next via Django's url_has_allowed_host_and_scheme against ALLOWED_HOSTS plus the current request host (necessary given the multi-subdomain setup).
  • Soft-delete is respected consistently across almost every lookup in IdentityQueryService/IdentityStateService/login/password-reset — deleted_at__isnull=True filters appear throughout rather than being an afterthought in only some queries.

7. Cross-App Integration Points

App How it touches users
teamcentral The other half of the old Employee model. SignupFlowService and SSOOnboardingService both directly call teamcentral.services.MemberLifecycleService.create_member(...) (and EmployeeLifecycleService, imported but not obviously called in the reviewed paths) to create the linked MemberProfile/EmploymentProfile at signup time. UserIdentity.employment_profile/.member_profile are read-only lazy pointers back into teamcentral.
mailer BaseAdapter.send_template_email and every signup/verification/reset flow route outbound email through mailer.engine.engine.EmailEngine. mailer.engine.verification_guard.handle_unverified_email is called directly by both the login adapter and web_login.py when EMAIL_NOT_VERIFIED is hit. mailer.throttling.flow_throttle.allow_flow gates password-reset sends.
audit UsersConfig.ready() registers all 3 models (UserIdentity, SignUpRequest, PasswordResetRequest) for automatic create/update audit tracking (see audit doc, §2.2). Login/logout/login-failed events are captured by audit.security.auth listening to Django's built-in auth signals — users doesn't emit these itself, audit observes them.
utilities assign_app_registry_permissions() (role-based permission assignment at signup/SSO-link time) and utilities.admin.* (the shared BaseAdminPage, AdminIconDecorator, filters) that UserIdentityAdmin and friends build on. utilities.services.is_admin and utilities.admin.app_registry both contain the redstar bypass duplicated from this app (§6.1).
fincore, teamcentral (again) Import AddressValidationError, MemberValidationError, TeamValidationError directly from users.exceptions for their own domain errors (verified: fincore/serializers/base/address.py, fincore/models/address.py, fincore/exceptions.py, teamcentral/services/{member_lifecycle_service,team_management_service,address_management_service}.py). This directly contradicts the "IDENTITY ONLY" schema-freeze intent stated in users/models/__init__.py — the freeze was enforced on models, but users/exceptions.py still holds non-identity exception classes (MemberValidationError, TeamValidationError, AddressValidationError, and an apparently fully unused LeaveValidationError, SupportTicketError) that other apps depend on. See §8.
genericissuetracker (3rd-party, via paystream.integrations.issuetracker) DjangoPlayIssueTrackerIdentityResolver (users/services/issuetracker_identity_resolver.py) subclasses the library's DefaultIdentityResolver and is the configured GENERIC_ISSUETRACKER_IDENTITY_RESOLVER — this is users' one deliberate public integration contract with the issue tracker, returning a small stable dict (id, email, is_authenticated, is_superuser, role_code) rather than exposing the model directly.
policyengine (presumed, not directly verified in this pass) assign_app_registry_permissions / group assignment at signup strongly suggests policyengine consumes the groups/roles users assigns, but this pass didn't read policyengine itself — flagged for confirmation when that app's doc is produced.
django-allauth (3rd-party) Not just a dependency — users/adapters/ is DjangoPlay's entire customization surface for allauth (ACCOUNT_ADAPTER / SOCIALACCOUNT_ADAPTER presumably point at CustomAccountAdapter/CustomSocialAccountAdapter, not verified directly but strongly implied by the adapter class shapes matching allauth's hook contract exactly).
djangorestframework-simplejwt (3rd-party) CustomTokenObtainSerializer/CustomTokenObtainPairView subclass SimpleJWT directly to add email-as-username support and the remember_me flag.
paystream (security infra) SignupFlowService/SSOOnboardingService call paystream.security.infra.signup_abuse_service.SignupAbuseService.analyze() directly at signup time — this is where configs/disposable_domains.txt, AbuseIPDB, and Turnstile checks actually live; users only consumes the resulting allow/challenge/block/hard_block verdict.

8. Known Gaps / Things Worth Confirming With the Team

  1. The redstar hardcoded bypass identity is scattered across ≥9 files in 3 apps, not centralized — see §6.1. Highest-priority item on this list given it touches login validation, superuser grants, and admin access simultaneously.
  2. Hardcoded SIMPLE_JWT signing key (paystream/app_settings/jwt.py) — shared platform-wide finding, but users is the app that actually uses it to sign tokens. See §6.2.
  3. remember_me refresh-token lifetime (1 minute) appears inverted relative to the non-remember-me default (1 day). See §6.3.
  4. users/exceptions.py still contains non-identity exception classes actively imported by fincore and teamcentral (MemberValidationError, TeamValidationError, AddressValidationError), directly contradicting the "IDENTITY ONLY" schema-freeze docstring. LeaveValidationError and SupportTicketError in the same file appear entirely unused anywhere in the codebase (dead code). Worth relocating the still-used ones to their owning apps' own exception modules, and removing the unused ones.
  5. users/constants.py is almost entirely dead/legacy — MEMBER_STATUS_CODES, EMPLOYMENT_STATUS_CODES, ROLE_CODES, DEPARTMENT_CODES, EMPLOYEE_TYPE_CODES, LEAVE_TYPE_CODES all describe teamcentral-domain concepts (departments, roles, leave types) that this app no longer owns per its own schema-freeze docstring. Not verified in this pass whether teamcentral actually imports from here or maintains its own duplicate copy — worth checking when documenting teamcentral, since duplicated master-data constants across two apps is a real drift risk.
  6. users/tests/test_phonenumber.py is broken as committed — it imports from users.models.employee import User, a module removed by migration 0003_drop_users_employee.py, and calls User.objects.create_superuser(...) with fields (department, role, approval_limit, employment_status) that don't exist on UserIdentity (they moved to teamcentral.EmploymentProfile). This test cannot currently pass or even import successfully — it's stale from before the Employee/UserIdentity split and wasn't updated or removed.
  7. users/tests/test_concurrency.py is entirely commented out — zero active test coverage for concurrent-update handling on the identity model, despite the file existing specifically to test that.
  8. AuthX Identity (authx-identity) is a pinned dependency with a full settings module but is not wired into any authentication code path in this app (or found anywhere else in webapp/) — see §1. Not a "bug" exactly, but worth resolving the discrepancy between what's configured and what's active before treating AuthX as this app's authentication backbone in documentation, onboarding, or architecture diagrams.
  9. license_file_view lives in users/views/ui/license.py — serving a license file has no identity relationship; likely just convenient routing rather than a deliberate ownership decision, worth relocating if users is meant to stay strictly identity-scoped per its own docstring.
  10. Two separate login entry points for the web (ConsoleLoginView at login/ and ApiLoginView at api-login/) exist side by side — not verified in this pass whether both are still actively used by the frontend or whether one is legacy; worth a quick check before assuming both are load-bearing.

9. Quick Reference — Adding a New Login/Signup Entry Point

Based on the verified pattern every existing entry point follows:

  1. Never re-implement login validation. Call UnifiedLoginService.validate_user(user) and handle its LoginValidationResult(ok, reason) — use map_reason_to_message(reason) for a user-facing string. (Exception in current code: the JWT path skips this — don't copy that, it's flagged as a gap in §4.4, not a pattern to replicate.)
  2. Never create a UserIdentity without also creating its teamcentral profile. Every existing creation path (manual signup, allauth signup, SSO onboarding) immediately calls into teamcentral.services.MemberLifecycleService.create_member(...) in the same atomic transaction. A UserIdentity with no MemberProfile will fail UnifiedLoginService's employment check assumptions downstream in surprising ways.
  3. Route all outbound email through mailer.engine.engine.EmailEngine, not Django's raw send_mail — BaseAdapter.send_template_email is the sanctioned entry point, and remember it silently no-ops for redstar@djangoplay.org (§6.1).
  4. If issuing a new kind of token, follow the SignUpRequest/PasswordResetRequest shape: TimeStampedModel + AuditFieldsModel, unique indexed token field with a distinct prefix, expires_at, a dedicated *ManagerService class owning create/validate/consume, and decide deliberately whether concurrent active tokens should be reused (verification-token style) or eagerly invalidated (password-reset style) — both patterns exist here for good, documented reasons; don't default to one without thinking about which fits.
  5. Do not add non-identity fields to UserIdentity or non-identity exceptions to users/exceptions.py. If it's HR/org/address-shaped, it belongs in teamcentral (or wherever the domain actually lives) — see §8.4-8.5 for what happens when this rule was bent.