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/_...
On this page ▾
- 1. Summary
- 2. Architecture
- 2.1 What UserIdentity actually owns (verified from the model)
- 2.2 Request-flow architecture — thin views/adapters, services own the rules
- 2.3 Two independent token systems, both owned by users
- 3. Implementation Details
- 4. Functionality — Identity Flows
- 4.1 Manual signup (email + password)
- 4.2 Allauth-driven signup (email confirmation flow / social, shared code path)
- 4.3 Email verification
- 4.4 Login (three entry points, one shared rule engine)
- 4.5 SSO / social login
- 4.6 Password reset
- 4.7 Session-based re-authentication (modal re-login without full page reload)
- 5. API Surface
- 5.1 API (views/api/v1/)
- 5.2 UI (views/ui/)
- 6. Security
- 6.1 A hardcoded backdoor identity: redstar / redstar@djangoplay.org
- 6.2 Hardcoded JWT signing key (shared platform-wide finding, directly relevant here)
- 6.3 remember_me lifetime looks inverted
- 6.4 Username enumeration via login error messages
- 6.5 What's genuinely solid here
- 7. Cross-App Integration Points
- 8. Known Gaps / Things Worth Confirming With the Team
- 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 inteamcentral,mailer,policyengine-adjacentutilities.admin.app_registry,paystream.security.infra,paystream.app_settings.authx,paystream.app_settings.jwt, andgenericissuetracker.
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)
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/:
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 inpaystream/app_settings/core.py), subclasses Django'sAbstractUser— so it retainsusername,password,first_name,last_name,is_staff,date_joined,last_login, and the standardgroups/user_permissionsM2M (remapped here torelated_name="user_identity_groups"/"user_identity_permissions"to avoid clashing with Django's defaultauth.User, which still exists inINSTALLED_APPSindirectly viadjango.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-historyon all three models (UserIdentity,SignUpRequest,PasswordResetRequest) — full field-level history table per model, independent of (and in addition to) the platform-wideauditapp's diff-basedAuditEventlog. - Audit registration:
UsersConfig.ready()registersusers.UserIdentity,users.SignUpRequest,users.PasswordResetRequestintoaudit.lifecycle.registry.AUDIT_TRACKED_MODELS— so create/update events on all three flow into the platform audit trail automatically (see theauditapp 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) andserializers/v1/{read,write}/per model — read and write serializers are intentionally separate classes, not one serializer withread_onlyflags sprinkled in. - Forms:
forms/admin/(Django-admin-facing forms forUserIdentity,SignUpRequest,PasswordResetRequest) andforms/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:
- 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-inconfigs/disposable_domains.txtallowlist — see thepaystreamdoc's fraud-scoring table for the full signal breakdown) → action is one ofallow/challenge/block/hard_block.block/hard_blockreject the signup outright with a generic error message (no detail leaked to the client);challengeis logged but currently still allowed through. - Enforces the identity invariant: one email = one
UserIdentity(_assert_identity_not_exists), and separately checks username uniqueness (raisesUsernameAlreadyTakenErrorif taken). - Creates the
UserIdentityrow (is_active=True,is_verified=False,sso_provider="EMAIL"). - Immediately calls into
teamcentral.services.MemberLifecycleService.create_member(...)to create the linkedMemberProfile— i.e. even thoughusersis 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. - Assigns default role-based permissions via
utilities.ui.dashboard.services.registry_permissions_service.assign_app_registry_permissions(user, default_role_code). - Creates an
allauth.account.models.EmailAddressrow (verified=False, primary=True) — allauth's own verification bookkeeping is kept in sync alongside DjangoPlay's ownSignUpRequesttoken. - Issues a verification token via
SignupTokenManagerService.create_for_user. - Queues a welcome email (
mailer.flows.member.signup.send_successful_signup_email_task, 30s countdown) viatransaction.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
SignUpRequestrows (bulkupdate(deleted_at=...), not per-row.save()— bypasses modelsave()/history hooks intentionally for this bulk cleanup). - Marks the allauth
EmailAddressverified. - Sets
user.is_verified = True. - Cross-app write: if the user has an
EmploymentProfile, sets itsemployment_statusto theEmploymentStatusrow withcode="ACTV". If aMemberProfileexists, sets itsstatustoMemberStatuswithcode="ACTV". This is the one place inusersthat directly mutatesteamcentralmodel 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:
user is None→USER_NOT_FOUNDdeleted_at is not None→ACCOUNT_DELETEDnot is_active→ACCOUNT_INACTIVEnot is_verified→EMAIL_NOT_VERIFIED- If an
EmploymentProfileexists: itsemployment_statusmust exist, not be soft-deleted/inactive, and havecode == "ACTV"— otherwiseEMPLOYMENT_NOT_ACTIVE. (Members without anEmploymentProfile— 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
usernamefield — if the value contains@,CustomTokenObtainSerializer.validate()looks up the realUserIdentityby email and swaps in the canonical username before delegating to the parent serializer. - A
remember_meflag, if true, mints a freshRefreshTokenwithREFRESH_TOKEN_LIFETIME_REMEMBER_ME(1 minute — see §6, this looks like an inverted default) instead of the standardREFRESH_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:
- Link by existing
sso_id+sso_provider— direct re-login for a previously-linked SSO account. - 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_providerattached to their existing identity, plusassign_app_registry_permissions(user_identity, "SSO"). - Create brand-new
UserIdentity+MemberProfile— username auto-derived from the email local-part with numeric suffixing on collision (_build_username_from_email),is_verified=Trueimmediately (no email-verification step for SSO signups, consistent with allauth'ssend_confirmation_mailoverride which suppresses the confirmation email entirely for social signups), andis_superuserset 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
UnifiedLoginServiceas one shared gate (aside from the JWT path and theredstarbypass) 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_urlandLoginRedirectHelperboth validatenextvia Django'surl_has_allowed_host_and_schemeagainstALLOWED_HOSTSplus 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=Truefilters 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
- The
redstarhardcoded 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. - Hardcoded
SIMPLE_JWTsigning key (paystream/app_settings/jwt.py) — shared platform-wide finding, butusersis the app that actually uses it to sign tokens. See §6.2. remember_merefresh-token lifetime (1 minute) appears inverted relative to the non-remember-me default (1 day). See §6.3.users/exceptions.pystill contains non-identity exception classes actively imported byfincoreandteamcentral(MemberValidationError,TeamValidationError,AddressValidationError), directly contradicting the "IDENTITY ONLY" schema-freeze docstring.LeaveValidationErrorandSupportTicketErrorin 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.users/constants.pyis almost entirely dead/legacy —MEMBER_STATUS_CODES,EMPLOYMENT_STATUS_CODES,ROLE_CODES,DEPARTMENT_CODES,EMPLOYEE_TYPE_CODES,LEAVE_TYPE_CODESall describeteamcentral-domain concepts (departments, roles, leave types) that this app no longer owns per its own schema-freeze docstring. Not verified in this pass whetherteamcentralactually imports from here or maintains its own duplicate copy — worth checking when documentingteamcentral, since duplicated master-data constants across two apps is a real drift risk.users/tests/test_phonenumber.pyis broken as committed — it importsfrom users.models.employee import User, a module removed by migration0003_drop_users_employee.py, and callsUser.objects.create_superuser(...)with fields (department,role,approval_limit,employment_status) that don't exist onUserIdentity(they moved toteamcentral.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.users/tests/test_concurrency.pyis entirely commented out — zero active test coverage for concurrent-update handling on the identity model, despite the file existing specifically to test that.- 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 inwebapp/) — 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. license_file_viewlives inusers/views/ui/license.py— serving a license file has no identity relationship; likely just convenient routing rather than a deliberate ownership decision, worth relocating ifusersis meant to stay strictly identity-scoped per its own docstring.- Two separate login entry points for the web (
ConsoleLoginViewatlogin/andApiLoginViewatapi-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:
- Never re-implement login validation. Call
UnifiedLoginService.validate_user(user)and handle itsLoginValidationResult(ok, reason)— usemap_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.) - Never create a
UserIdentitywithout also creating itsteamcentralprofile. Every existing creation path (manual signup, allauth signup, SSO onboarding) immediately calls intoteamcentral.services.MemberLifecycleService.create_member(...)in the same atomic transaction. AUserIdentitywith noMemberProfilewill failUnifiedLoginService's employment check assumptions downstream in surprising ways. - Route all outbound email through
mailer.engine.engine.EmailEngine, not Django's rawsend_mail—BaseAdapter.send_template_emailis the sanctioned entry point, and remember it silently no-ops forredstar@djangoplay.org(§6.1). - If issuing a new kind of token, follow the
SignUpRequest/PasswordResetRequestshape:TimeStampedModel + AuditFieldsModel, unique indexedtokenfield with a distinct prefix,expires_at, a dedicated*ManagerServiceclass 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. - Do not add non-identity fields to
UserIdentityor non-identity exceptions tousers/exceptions.py. If it's HR/org/address-shaped, it belongs inteamcentral(or wherever the domain actually lives) — see §8.4-8.5 for what happens when this rule was bent.