--- since: 1.2.1 --- # DjangoPlay — `users` App > 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 `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/__init__.py` states in caps — this app is explicitly **schema-frozen** to identity concerns only: > "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 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//...`, `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//` | `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///`, `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.