diff --git a/README.md b/README.md index 8418eeca..29fb413e 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,18 @@ Deactivate with `deactivate` when you're done. ## Optional - __Create an admin user__ - - With uv: + - Set `LITEFILE_STAFF_BOOTSTRAP_USERNAME` and a unique + `LITEFILE_STAFF_BOOTSTRAP_PASSWORD` through your shell or secret manager, then: ```bash - uv run python manage.py createsuperuser + cd efile_app + uv run python manage.py bootstrap_staff ``` - Admin will be available at `/admin/` after you start the server. + Store the generated TOTP setup URI in your password manager. Alternatively, + supply its Base32 secret using `LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET`. + With the default private path, open http://127.0.0.1:8000/staff-7c83f0a2/. + Every staff role requires a local password and TOTP, including superusers. + See the [staff setup and local demo runbook](docs/developer-notes/staff-privacy-and-analytics.md) + for local sample data, deployment switches, and recovery. - __Static files__ During development, static files are served automatically. No `collectstatic` is needed. diff --git a/docs/developer-notes/staff-privacy-and-analytics.md b/docs/developer-notes/staff-privacy-and-analytics.md new file mode 100644 index 00000000..441cb30e --- /dev/null +++ b/docs/developer-notes/staff-privacy-and-analytics.md @@ -0,0 +1,369 @@ +# Staff privacy requests and aggregate usage + +This runbook accompanies issue #162. The staff area uses Django admin with +purpose-built lookup, privacy-request, and reporting views. It is not linked +from public pages. Set `LITEFILE_STAFF_PATH` to a private URL segment and share +that URL through the staff onboarding channel. The URL is not an authentication +secret: local passwords, TOTP, and jurisdiction-scoped roles enforce access. + +## Deployment and onboarding + +Install the locked dependencies and run `uv run python manage.py migrate` +before enabling collection. Existing object ownership is inventoried by the +migration; it does not backfill usage statistics or contact S3. + +### Bootstrap from environment variables + +From a trusted deployment console, supply the following through shell variables, +your password manager, or deployment secrets. There are no default credentials. +Global environment variables take precedence over the development `.env` file. + +| Variable | Purpose | +| --- | --- | +| `LITEFILE_STAFF_BOOTSTRAP_USERNAME` | Required initial superuser name | +| `LITEFILE_STAFF_BOOTSTRAP_PASSWORD` | Required unique password that passes Django password validation | +| `LITEFILE_STAFF_BOOTSTRAP_EMAIL` | Optional staff contact email | +| `LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET` | Optional Base32 secret containing 20–40 random bytes; otherwise generated | + +With those variables set, run: + +```bash +cd efile_app +uv run python manage.py migrate +uv run python manage.py bootstrap_staff +``` + +Bootstrap creates an active local superuser and a confirmed TOTP device together. +If no TOTP secret was supplied, it prints the newly generated setup URI once. +If a secret was supplied, it does not print the credentials. Repeating the command +leaves existing credentials and roles unchanged; it refuses to promote a normal +account or overwrite an account without a working TOTP device. It never runs +automatically at server startup. Remove bootstrap credential variables from the +runtime environment after setup; they are not needed to sign in or run the app. + +For a Bash setup without putting a password in command history: + +```bash +export LITEFILE_STAFF_BOOTSTRAP_USERNAME="your-admin-name" +read -r -s -p "Unique administrator password: " LITEFILE_STAFF_BOOTSTRAP_PASSWORD +echo +export LITEFILE_STAFF_BOOTSTRAP_PASSWORD +uv run python manage.py bootstrap_staff +unset LITEFILE_STAFF_BOOTSTRAP_PASSWORD LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET +``` + +The alternative interactive setup remains available: + +```bash +cd efile_app +uv run python manage.py createsuperuser +uv run python manage.py provision_staff_totp STAFF_USERNAME +``` + +The second command prints an `otpauth://` secret. Import it into an authenticator +using its manual setup/import feature. Treat the console output as a credential; +do not save it in logs, tickets, screenshots, or shared shell transcripts. Staff +sign in with a local password and a fresh TOTP code. Court authentication is +never called by the staff sign-in form. Password failures are limited to ten per +username in fifteen minutes; TOTP has its own throttle and replay prevention. +Staff sessions require reauthentication after fifteen minutes, including +superusers. Staff identities cannot be used for ordinary filer workflows. + +The superuser can create staff accounts in the private admin site, provision +their authenticator from the account's TOTP setup link, and grant one or both +roles independently for each configured jurisdiction: + +| Role | Access | +| --- | --- | +| Account management | Minimal account/draft lookup, session revocation, verified privacy requests | +| Aggregate reporting | Suppressed reports and, when enabled, CSV exports | +| Superuser | Both roles across configured jurisdictions and staff provisioning | + +`is_staff` alone gives no access. Analysts cannot open account/request pages or +grant roles. Account managers cannot export reports without the reporting role. +Role revocation and account deactivation take effect on the next request. +No ordinary litigant model is registered for unrestricted admin editing. + +For recovery, verify the administrator through the staff recovery procedure, +then use `provision_staff_totp STAFF_USERNAME --reset` from the deployment console. +This replaces the device and revokes that staff account's sessions. There are no +password-only or static-token recovery routes in the backend. Protect deployment +console access, database credentials, and TOTP secrets stored in the database. +Use the existing production HTTPS, secure-cookie, and HSTS settings. Ensure the +backend and database have encryption at rest and restricted operator access. + +### Local demo + +Use the development settings with the local SQLite database. After migrations +and staff bootstrap, create synthetic lookup and report fixtures: + +```bash +cd efile_app +uv run python manage.py seed_staff_demo +LITEFILE_ANALYTICS_EXPORT_ENABLED=true \ +LITEFILE_PRIVACY_DELETION_ENABLED=true \ +uv run python manage.py runserver 127.0.0.1:8001 +``` + +Open when using the default +`LITEFILE_STAFF_PATH`. Sign in with your bootstrapped local administrator password +and the six-digit TOTP from your password manager. Store the **setup secret** or +the `otpauth://` URI; a displayed six-digit code expires after thirty seconds. +Port 8001 keeps this local demo separate from a filing app already using port +8000. Use SHA-1, six digits, and a thirty-second period. If you change +`LITEFILE_STAFF_PATH`, use that segment in the URL instead. + +Staff pages use `Referrer-Policy: same-origin`: browser forms retain the origin +needed for Django's CSRF checks, while requests to other origins omit referrer +information. Do not use `no-referrer` for these pages or trust the literal +`null` origin; native form submissions under that policy can fail CSRF checks. +After changing this header, restart the server and reload the login page before +submitting it. See [the browser behavior documented by MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#effect_on_the_origin_header). + +The seed command creates five synthetic filers and ten drafts per configured +jurisdiction, plus started/review aggregates for the previous complete UTC month. +It prints the report dates. In Account lookup, search for +`litefile-demo-illinois-1@example.invalid` in Illinois, or the corresponding +configured jurisdiction. In Aggregate usage, select that jurisdiction and the +printed dates, monthly grouping, and Total; each new/existing cell has five +contributing demo accounts. Repeated runs preserve existing fixtures and counts. +Demo users have unusable passwords and are excluded from ordinary collection. +No documents are uploaded and no court accounts or filings are created. + +Demo seeding refuses deployed hosts, non-debug settings, and non-SQLite databases. +Normal collection remains disabled under development settings; the seed command +explicitly creates synthetic aggregates for testing. The command above enables +CSV exports and verified deletion in the local demo. Deleting synthetic accounts +removes their local operational data while the anonymous counters remain. +Keep production credentials and data out of this demo database. + +## Enablement and policy decisions + +The following environment switches default to `false`: + +```text +LITEFILE_ANALYTICS_ENABLED=true +LITEFILE_ANALYTICS_EXPORT_ENABLED=true +LITEFILE_PRIVACY_DELETION_ENABLED=true +``` + +Collection also excludes `DEBUG` deployments, stand-in-document deployments, +staff accounts, and designated test accounts. Before a production test, mark its +local account with `uv run python manage.py exclude_analytics_account ACCOUNT_ID`. +This excludes future events; it cannot subtract already anonymous aggregates. + +Wait until every web and worker instance runs the new code before enabling +deletion. Older instances do not enforce the freeze checks. Finish or stop old +workers during rollout; do not enable destructive actions during a mixed-version +deployment. + +Before enabling deletion or exports, the service owner must approve verification +and response procedures, retention exceptions, backup handling, allowed report +dimensions, and disclosure thresholds. The conservative implementation floor is +five distinct contributing local accounts per cell; increase it with +`LITEFILE_ANALYTICS_MIN_CONTRIBUTORS`. This is a disclosure safeguard, not a legal +determination or a guarantee against identification using outside information. + +Choose `LITEFILE_STAFF_REQUEST_RETENTION_DAYS` (default 90) for resolved request +and audit records. Open work and outstanding backup/provider dispositions are +not automatically pruned. Review aging open requests instead of discarding +their restricted manifests. No general operational-retention expiry is added +by this feature; retain the existing service policy until a separate retention +decision changes it. + +## Verify and process a request + +1. Receive the request at the jurisdiction's existing contact address. Verify + identity and authority using an approved contact-channel procedure. Never + ask for passwords or copies of court filings as identity evidence. Keep + intake correspondence in the approved support system, not in free-text + fields in this backend. +2. Find the explicit local account by ID, email plus jurisdiction, or draft ID. + Matching emails across jurisdictions are separate accounts and separate + approvals. The lookup shows metadata and counts, without document contents, + payment information, session credentials, or raw session payloads. +3. Record either selected owned drafts or all LITEFile-held data for this local + account. Staff accounts are excluded. Disputed ownership, another person's + records, and unclaimed handoffs require manual review; do not infer ownership + from names, document text, or a matching email. + Select **Review deletion** to save the scope and open its inventory preview. + This step creates a deletion request; deletion takes place after the final + confirmation. Existing pending requests can be reopened through + **Continue deletion review** on the account page or **Deletion requests** + on the staff home page. +4. Confirm the identity/authority checkbox and select **Verify and continue**. + Review the counts and blockers, then type the request reference and select + **Permanently delete account** or **Permanently delete selected filings**. + Failed attempts provide **Retry deletion** after blockers are resolved. + A signed, expiring + preview prevents confirmation of a changed inventory without another review. + GET requests never verify, revoke sessions, provision TOTP, or delete data. +5. Reconcile `submitting`/`error` filings with the filing service before deletion. + An uncertain timeout is not a confirmed failure. Stop or finish active + extraction workers. Shared objects and correction chains outside scope block + automatic deletion; expand the scope only with verified authority, or record + an approved operator disposition outside this workflow. +6. Processing freezes the affected drafts and revokes affected browser sessions. + Uploads, party edits, extraction claims, and stale model saves cannot restore + frozen or erased drafts. Account-wide requests revoke every stored session + for that account. Selected-draft requests revoke sessions pointing to those + drafts and legacy sessions holding copied filing data. + Filing operations for the account pause while cleanup is incomplete; unrelated + stored drafts and plans remain intact and become usable again after completion. +7. Both original and prepared S3 objects, prior owned copies, versions, and delete + markers are erased. The restricted manifest records object progress before + database deletion. A failed object leaves the request in needs attention, + with frozen records and retry information intact. Missing objects are safe to + retry. No partial attempt is reported as complete. +8. Successful cleanup removes the scoped database records, clears target IDs and + object keys from the request, and keeps only counts, staff attribution, dates, + the random request reference, and the outcome. Aggregate counters remain. + +The request page distinguishes **live-system deletion completed** from unresolved +backup/provider work. Mark the external disposition resolved only after the +approved backup expiry/isolation and provider disposition are confirmed. If an +exception applies, refer it to the person authorized to approve exceptions; +do not mark it resolved merely to close the queue. + +Suggested response: “We deleted the specified copies held in LITEFile's active +systems. This does not withdraw a court filing or delete court or Tyler records. +[Describe the actual backup/provider disposition and any remaining exception.] +Anonymous aggregate usage counts remain.” Do not tell a user that all copies +everywhere were erased while required work remains outstanding. + +## Data inventory and boundaries + +| Store | Automated scope and retention | +| --- | --- | +| Local profile/preferences | Removed on account-wide deletion; excludes staff accounts | +| Plans and archived-case metadata | Removed with the selected local account; preserved for draft-only requests | +| Drafts and corrections | Removed only within the verified scope; external correction relationships block removal | +| Parties, submission snapshots/responses | Cascade with scoped drafts | +| Extractions, evidence, provenance, handoff receipts/replacements | Cascade with scoped drafts | +| Pending activations | Matching account email in the selected jurisdiction only | +| Database sessions and session-held court tokens | Revoked by account or affected-draft scope; no payloads shown to staff | +| S3 current/original/prior objects | Exact owned keys erased, including versions and markers; outside-scope references block deletion | +| Upload ownership inventory | Operational only; outlives removed document rows, cascades with the erased draft | +| Usage outbox, contributor and matter deduplication | Operational only; event dimensions clear after rollup, outbox drains before erasure, account links cascade on account deletion | +| Aggregate counters | Retained indefinitely without account/session/draft IDs or lookup hashes | +| Request manifests | Restricted and retained while cleanup is unfinished; cleared after successful live-system cleanup | +| Request/audit history | Minimal counts/outcomes; pruned after configured retention when external disposition is resolved | +| Login throttles | Keyed hashes, no raw usernames/IPs; pruned after one day by the usage worker | + +Extraction workers use temporary directories with context-managed cleanup. +Deletion defers while processing is active. After a crashed worker, confirm the +process stopped and clear its abandoned temporary directory through the existing +operator procedure before retrying. This workflow does not scan arbitrary host +directories or claim to erase provider caches. Application configuration caches +contain court configuration, not filer documents. Pre-feature orphan uploads, +failed rollback cleanup without a durable owner, host crash residue, old logs, +backups, and external-provider copies require operator inventory/disposition. + +Application staff audits and new storage/authentication logs omit payloads, +filenames, tokens, and lookup emails. Lookup terms use POST and short-lived staff +sessions. Configure proxy/access/error-monitoring logs to omit staff request +bodies, query strings, account locators, authenticator provisioning responses, +and cookies. Apply the approved log retention policy to older logs. + +S3 IAM must permit `GetBucketVersioning`, `ListBucketVersions`, `DeleteObject`, +and `DeleteObjectVersion` for the application's private storage. Object lock, +MFA deletion requirements, or denied version access fail closed and need operator +resolution. A delete marker alone is not permanent erasure; see +[AWS's version deletion documentation](https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeletingObjectVersions.html). +Shared keys are retained as blockers, including keys inventoried from earlier +application-authored submission snapshots. The tool never silently deletes an +outside-scope copy to satisfy a request. + +Backups need a documented expiry or approved isolation policy and a restore +procedure. After a restore, keep the application offline and reapply the verified +deletion ledger/support dispositions before making restored accounts or uploads +available. Do not simply restore an old database and resume service: its deleted +accounts and S3 references could become live again. Court filings, Tyler +accounts, and AI-provider retention require their own disposition; there is no +automatic provider deletion integration in this MVP. + +## Reporting definitions + +Events record their UTC date, configured jurisdiction, and new/existing/unknown +category at the time they occur. Historical counts are not rewritten when the +filer changes an answer. + +| Metric | Counting rule | +| --- | --- | +| `started` | Once per created owned draft or first handoff claim; unclaimed handoffs excluded | +| `review` | Once per draft reaching the validated review screen | +| `submission_attempt` | Once per claimed logical operation; duplicate clicks/transport retries are not additional attempts | +| `transmitted` | Once per draft with confirmed delivery to the filing service; not court acceptance | +| `submission_error` | Once per logical operation with a confirmed pre-call failure or API rejection; ambiguous outcomes excluded | +| `document_uploaded` | Once per document row; includes preparation outcome and original PDF form-field presence | +| `matter_first_used` | First confirmed transmission into a known matter for this local account | + +Detailed monthly marginals cover configured case type, filer side, self versus +someone else, five-digit filer ZIP, preparation outcome, original PDF form-field +presence, and a first/2–5/6+ transmission frequency band. They are independent +breakdowns, not a row combining ZIP with case details. Unknown answers stay +unknown. `no_form_fields` includes both ordinary PDFs and PDFs already flattened; +it is not a claim that every unchanged upload was previously flattened. Word +files and legacy unknowns are separate from tested PDF input. + +Case types use deployment-owned configuration keys; unfamiliar court types are +`other_or_unknown`. Extend configured matches to make those reports more useful. +Matter identity follows a plan or a known court/case identifier, with a draft +fallback when neither exists. Matters are per local account, not unique cases +across all people or a count of unique human filers. Deduplication records are +operational data, not anonymous analytics. No user-level export is offered. + +The outbox and counters update atomically with database uniqueness constraints. +Worker retries do not double-count. A queue failure logs a fixed collection +degradation message without stopping the filing; alert on that message and worker +failures. The dashboard shows retained coverage, freshness, and pending work. +Coverage starts at deployment; there is no usage backfill. Counters survive +session expiry, restarts, and contributing account deletion. + +Daily core reports and full-calendar-month reports use fixed UTC buckets. +Detailed reports require full calendar months. If any cell in the requested +breakdown has fewer than the configured contributor threshold, that whole +breakdown/period is suppressed, including its subtotals and rare labels. No partial +detailed breakdown can be subtracted from a core total. Dashboard and CSV share +the same service. +No arbitrary combined demographic breakdowns, free text, names, document text, +case numbers, IP addresses, credentials, or persistent tracking IDs are stored +in the permanent counters. Contribution thresholds count local accounts, not +unique people. Review the disclosure policy before sharing reports externally. + +## Workers, monitoring, and rollback + +Schedule these commands using the deployment's existing scheduler: + +The existing extraction supervisor in `fly.toml` and `compose.yml` also runs +usage rollups once a minute, including while a PDF child is running. It catches +analytics failures independently so extraction continues. An additional usage +worker is optional; concurrent rollups are idempotent. The standalone command +is useful for draining a backlog and for deployments without that supervisor. + +```bash +uv run python manage.py process_usage_events # every minute; batches of 1,000 +uv run python manage.py process_privacy_requests # preview interrupted confirmed work +uv run python manage.py process_privacy_requests --apply # retry confirmed work after reviewing failures +uv run python manage.py prune_staff_requests # daily +uv run python manage.py clearsessions # existing session expiry housekeeping +``` + +The retry command only resumes previously confirmed processing or storage failures; +it never verifies or automatically starts a newly received/verified request. It +uses the original responsible operator's current role grants. Revoked permission +requires operator reassignment/review instead of bypassing access control. + +Alert on growing outbox backlog, stale freshness, collection degradation logs, +interrupted processing, storage failures, and old unresolved external dispositions. +Use a staging fixture with original/prepared/versioned/shared uploads to validate +IAM, local TOTP onboarding, reports, and worker restarts before enabling production +switches. Automated tests use mocked storage and never submit live filings. + +To disable collection, export, or deletion, set its switch to `false` and restart +the application and workers. Keep existing counters and open manifests. Do not +unfreeze drafts or discard manifests after partial erasure. Turning a switch off +cannot restore already erased objects. Existing migration/schema must remain while +these code paths are deployed; rollback code and worker configuration together. +The authentication implementation uses +[django-otp's TOTP/admin integration](https://django-otp-official.readthedocs.io/en/stable/auth.html). diff --git a/efile_app/.env.example b/efile_app/.env.example index ec02abc6..36c5e4cb 100644 --- a/efile_app/.env.example +++ b/efile_app/.env.example @@ -1,7 +1,22 @@ -# Django Settings +# Django settings DJANGO_SECRET_KEY=your-secret-key-here DJANGO_LOG_LEVEL=DEBUG +# Private staff tools: no shared/default administrator credentials. +# Set these in your shell or secret manager, then run: +# uv run python manage.py bootstrap_staff +# Existing accounts are never promoted or overwritten by bootstrap. +LITEFILE_STAFF_BOOTSTRAP_USERNAME= +LITEFILE_STAFF_BOOTSTRAP_PASSWORD= +LITEFILE_STAFF_BOOTSTRAP_EMAIL= +# Optional Base32 TOTP setup secret (20–40 random bytes). +# Leave blank to print a newly generated setup URI once on creation. +LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET= +LITEFILE_STAFF_PATH=staff-7c83f0a2 +LITEFILE_ANALYTICS_ENABLED=false +LITEFILE_ANALYTICS_EXPORT_ENABLED=false +LITEFILE_PRIVACY_DELETION_ENABLED=false + # Database (optional - leave empty to use SQLite) DATABASE_URL=postgresql://user:password@localhost:5432/dbname diff --git a/efile_app/efile/apps.py b/efile_app/efile/apps.py index a7640b4f..a12723df 100644 --- a/efile_app/efile/apps.py +++ b/efile_app/efile/apps.py @@ -4,6 +4,7 @@ class EfileConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "efile" + verbose_name = "LITEFile" def ready(self): # Registers the checks in efile/checks.py by importing them. diff --git a/efile_app/efile/authentication.py b/efile_app/efile/authentication.py index 4eeb201f..85b471d2 100644 --- a/efile_app/efile/authentication.py +++ b/efile_app/efile/authentication.py @@ -13,12 +13,14 @@ class SuffolkEFileBackend(BaseBackend): def authenticate(self, request, username=None, password=None, **kwargs): - logger.info("Trying auth?") - jurisdiction = kwargs.get("jurisdiction", get_jurisdiction_from_request(request)) if not username or not password or not jurisdiction: return None + # A staff password must also stay local if entered on a filer form. + if User.objects.filter(username__iexact=username, is_staff=True).exists(): + return None + try: try: auth_data = auth_with_tyler_api(username, password, jurisdiction) @@ -29,23 +31,27 @@ def authenticate(self, request, username=None, password=None, **kwargs): request.efsp_unavailable = True return None if not auth_data or "tokens" not in auth_data: - logger.info("Tyler auth failed for user %s", username) + logger.info("Court authentication failed") return None request.session["auth_tokens"] = auth_data["tokens"] - logger.info("Auth data: %s", auth_data) - user = self._get_or_create_user(username, auth_data, jurisdiction) + if not user.is_active or user.is_staff: + request.session.pop("auth_tokens", None) + return None # TODO(brycew): actually write these? # if request: # self._store_tokens_in_session(request, auth_data, jurisdiction) - logger.info("Successfully auth'd user: %s", username) + logger.info("Court authentication succeeded") request.session["user_email"] = user.email return user except Exception: - logger.exception("Error during auth for user: %s", username) + if request is not None: + request.session.pop("auth_tokens", None) + request.session.pop("user_email", None) + logger.error("Court authentication failed unexpectedly") return None def get_user(self, user_id): diff --git a/efile_app/efile/management/commands/bootstrap_staff.py b/efile_app/efile/management/commands/bootstrap_staff.py new file mode 100644 index 00000000..f977b23c --- /dev/null +++ b/efile_app/efile/management/commands/bootstrap_staff.py @@ -0,0 +1,80 @@ +"""Explicit, one-time environment bootstrap with no default credentials.""" + +import base64 +import binascii +import os +import secrets + +from django.conf import settings +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand, CommandError +from django.core.validators import validate_email +from django.db import IntegrityError, transaction +from django_otp.plugins.otp_totp.models import TOTPDevice + +from efile.models import UserProfile + + +class Command(BaseCommand): + help = "Bootstrap a local-password/TOTP superuser from LITEFILE_STAFF_BOOTSTRAP_* environment variables." + + def handle(self, *args, **options): + username = os.getenv("LITEFILE_STAFF_BOOTSTRAP_USERNAME", "").strip() + if not username: + raise CommandError("Set LITEFILE_STAFF_BOOTSTRAP_USERNAME; there is no default account.") + existing = UserProfile.objects.filter(username=username).first() + if existing: + if not (existing.is_active and existing.is_staff and existing.is_superuser): + raise CommandError("An incompatible account already exists. Bootstrap cannot promote or overwrite it.") + if not TOTPDevice.objects.filter(user=existing, confirmed=True).exists(): + raise CommandError( + "The account exists without TOTP. Use provision_staff_totp from the trusted console." + ) + self.stdout.write("Staff account already provisioned; password, roles, and TOTP unchanged.") + return + + password = os.getenv("LITEFILE_STAFF_BOOTSTRAP_PASSWORD", "") + if not password: + raise CommandError("Set LITEFILE_STAFF_BOOTSTRAP_PASSWORD to a unique password; there is no default.") + email = os.getenv("LITEFILE_STAFF_BOOTSTRAP_EMAIL", "").strip() + user = UserProfile(username=username, email=email, is_active=True, is_staff=True, is_superuser=True) + try: + UserProfile._meta.get_field("username").clean(username, user) + if email: + validate_email(email) + validate_password(password, user=user) + except ValidationError as error: + # Never echo supplied values, including a password or TOTP secret. + raise CommandError( + "Invalid username, email, or password. Use a valid username and a strong unique password." + ) from error + + secret = os.getenv("LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET", "").strip().upper().replace(" ", "").rstrip("=") + supplied_secret = bool(secret) + if supplied_secret: + try: + key = base64.b32decode(secret + "=" * (-len(secret) % 8)) + except (binascii.Error, ValueError) as error: + raise CommandError("LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET must be a valid Base32 secret.") from error + if not 20 <= len(key) <= 40: + raise CommandError("Use a TOTP secret containing 20–40 random bytes (32–64 Base32 characters).") + else: + key = secrets.token_bytes(20) + + try: + with transaction.atomic(): + user.set_password(password) + user.save() + device = TOTPDevice.objects.create(user=user, name="Staff authenticator", key=key.hex(), confirmed=True) + except IntegrityError as error: + raise CommandError( + "Bootstrap conflicted with another account creation. No credentials were overwritten." + ) from error + + self.stdout.write(f"Staff superuser created. Sign in at /{settings.LITEFILE_STAFF_PATH}/.") + if supplied_secret: + self.stdout.write("TOTP configured from the supplied secret; credentials are not printed.") + else: + self.stdout.write("Store this newly generated TOTP setup URI securely; it is printed only on creation:") + self.stdout.write(device.config_url) diff --git a/efile_app/efile/management/commands/exclude_analytics_account.py b/efile_app/efile/management/commands/exclude_analytics_account.py new file mode 100644 index 00000000..d0ffeafe --- /dev/null +++ b/efile_app/efile/management/commands/exclude_analytics_account.py @@ -0,0 +1,15 @@ +from django.core.management.base import BaseCommand, CommandError + +from efile.models import UserProfile + + +class Command(BaseCommand): + help = "Exclude a designated test account from future reporting; existing anonymous counters are not changed." + + def add_arguments(self, parser): + parser.add_argument("account_id", type=int) + + def handle(self, *args, **options): + if not UserProfile.objects.filter(pk=options["account_id"]).update(analytics_excluded=True): + raise CommandError("Local account not found.") + self.stdout.write("Account excluded from future reporting.") diff --git a/efile_app/efile/management/commands/process_document_extractions.py b/efile_app/efile/management/commands/process_document_extractions.py index 912f993e..b0ef8ded 100644 --- a/efile_app/efile/management/commands/process_document_extractions.py +++ b/efile_app/efile/management/commands/process_document_extractions.py @@ -1,8 +1,10 @@ +import io import logging import multiprocessing import time from django.conf import settings +from django.core.management import call_command from django.core.management.base import BaseCommand from django.db import close_old_connections @@ -51,8 +53,22 @@ def handle(self, *args, **options): renew_extraction_lease, ) + next_rollup = 0.0 + + def rollup_if_due(): + nonlocal next_rollup + now = time.monotonic() + if now < next_rollup: + return + next_rollup = now + 60 + try: + call_command("process_usage_events", stdout=io.StringIO()) + except Exception: + logger.error("Usage rollup failed; queued events retained for retry") + while True: close_old_connections() + rollup_if_due() job = claim_next_extraction() if job is None: if options["once"]: @@ -73,6 +89,7 @@ def handle(self, *args, **options): if not child.is_alive(): break close_old_connections() + rollup_if_due() if time.monotonic() >= deadline or not renew_extraction_lease(job.pk, job.claim_token): child.terminate() break diff --git a/efile_app/efile/management/commands/process_privacy_requests.py b/efile_app/efile/management/commands/process_privacy_requests.py new file mode 100644 index 00000000..d5bb0d04 --- /dev/null +++ b/efile_app/efile/management/commands/process_privacy_requests.py @@ -0,0 +1,28 @@ +from django.core.management.base import BaseCommand + +from efile.models import PrivacyRequest +from efile.services.privacy import process_request + + +class Command(BaseCommand): + help = "Resume previously confirmed deletion work. Preview by default; --apply retries storage failures/interrupted processing." + + def add_arguments(self, parser): + parser.add_argument("--apply", action="store_true") + + def handle(self, *args, **options): + requests = PrivacyRequest.objects.filter( + status__in=["processing", "attention"], outcome__in=["", "storage_failed"] + ) + for item in requests.order_by("created_at"): + if not item.verified_at or not item.operator_id: + continue + if not options["apply"]: + self.stdout.write(f"Pending confirmed request {item.reference}.") + continue + try: + result = process_request(item.pk, item.operator) + except Exception: + self.stderr.write(f"Request {item.reference} requires operator attention.") + else: + self.stdout.write(f"Request {item.reference}: {result.status} / {result.outcome}.") diff --git a/efile_app/efile/management/commands/process_usage_events.py b/efile_app/efile/management/commands/process_usage_events.py new file mode 100644 index 00000000..de83ee4f --- /dev/null +++ b/efile_app/efile/management/commands/process_usage_events.py @@ -0,0 +1,19 @@ +from datetime import timedelta + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from efile.models import StaffLoginThrottle +from efile.services.analytics import drain_events + + +class Command(BaseCommand): + help = "Roll up queued usage events without double-counting. Run at least every minute." + + def handle(self, *args, **options): + try: + drain_events() + except Exception: + raise CommandError("Usage rollup failed; queued events remain available for retry.") from None + StaffLoginThrottle.objects.filter(window_started__lt=timezone.now() - timedelta(days=1)).delete() + self.stdout.write("Usage event batch processed.") diff --git a/efile_app/efile/management/commands/provision_staff_totp.py b/efile_app/efile/management/commands/provision_staff_totp.py new file mode 100644 index 00000000..16bdcbab --- /dev/null +++ b/efile_app/efile/management/commands/provision_staff_totp.py @@ -0,0 +1,37 @@ +"""Console bootstrap/recovery: never create a password-only staff bypass.""" + +from django.contrib.sessions.models import Session +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django_otp.plugins.otp_totp.models import TOTPDevice + +from efile.models import UserProfile +from efile.services.privacy import account_sessions + + +class Command(BaseCommand): + help = "Provision a staff TOTP authenticator. Deliver the printed secret securely; --reset revokes all sessions." + + def add_arguments(self, parser): + parser.add_argument("username") + parser.add_argument("--reset", action="store_true") + + @transaction.atomic + def handle(self, *args, **options): + user = ( + UserProfile.objects.select_for_update() + .filter(username=options["username"], is_staff=True, is_active=True) + .first() + ) + if user is None: + raise CommandError("An active local staff account is required.") + devices = TOTPDevice.objects.filter(user=user) + if devices.exists() and not options["reset"]: + raise CommandError( + "An authenticator already exists. Use --reset only after verifying the recovery request." + ) + if options["reset"]: + devices.delete() + Session.objects.filter(pk__in=[s.pk for s in account_sessions(user)]).delete() + device = TOTPDevice.objects.create(user=user, name="Staff authenticator", confirmed=True) + self.stdout.write(device.config_url) diff --git a/efile_app/efile/management/commands/prune_staff_requests.py b/efile_app/efile/management/commands/prune_staff_requests.py new file mode 100644 index 00000000..6e8bd277 --- /dev/null +++ b/efile_app/efile/management/commands/prune_staff_requests.py @@ -0,0 +1,21 @@ +from datetime import timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone + +from efile.models import PrivacyRequest, StaffAudit + + +class Command(BaseCommand): + help = "Prune resolved request/audit records after the configured retention period; never discard open manifests." + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(days=max(1, settings.LITEFILE_STAFF_REQUEST_RETENTION_DAYS)) + PrivacyRequest.objects.filter( + status="completed", external_cleanup_pending=False, completed_at__lt=cutoff + ).delete() + # Retain audits for unresolved requests even if their processing is old. + references = PrivacyRequest.objects.values("reference") + StaffAudit.objects.filter(created_at__lt=cutoff).exclude(reference__in=references).delete() + self.stdout.write("Resolved staff records pruned; open work retained.") diff --git a/efile_app/efile/management/commands/seed_staff_demo.py b/efile_app/efile/management/commands/seed_staff_demo.py new file mode 100644 index 00000000..97b9b8d4 --- /dev/null +++ b/efile_app/efile/management/commands/seed_staff_demo.py @@ -0,0 +1,83 @@ +"""Explicit local-only synthetic fixtures, separate from production collection.""" + +import os +from datetime import UTC, datetime, timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from efile.models import FilingDraft, UsageEvent, UserProfile +from efile.services.analytics import count_event +from efile.utils.config_loader import config_loader +from efile.workflow import ExistingCase + + +class Command(BaseCommand): + help = "Seed synthetic staff lookup and reporting fixtures; only allowed with DEBUG and a local SQLite database." + + @transaction.atomic + def handle(self, *args, **options): + if ( + not settings.DEBUG + or os.getenv("FLY_APP_NAME") + or settings.DATABASES["default"]["ENGINE"] != "django.db.backends.sqlite3" + ): + raise CommandError( + "Demo seeding requires local DEBUG settings and SQLite; it cannot run on deployed settings." + ) + today = timezone.now().astimezone(UTC).date() + end = today.replace(day=1) - timedelta(days=1) + start = end.replace(day=1) + occurred_at = datetime(start.year, start.month, 15, 12, tzinfo=UTC) + for jurisdiction in config_loader.get_available_jurisdictions(): + for number in range(1, 6): + username = f"litefile-demo-{jurisdiction}-{number}" + user, created = UserProfile.objects.get_or_create( + username=username, + defaults={ + "email": f"{username}@example.invalid", + "tyler_jurisdiction": jurisdiction, + "analytics_excluded": True, + }, + ) + if not created and ( + user.is_staff or not user.analytics_excluded or user.tyler_jurisdiction != jurisdiction + ): + raise CommandError( + "A demo username conflicts with an existing account; no accounts were overwritten." + ) + if created: + user.set_unusable_password() + user.save(update_fields=["password"]) + for kind, existing_case, side in ( + ("new", ExistingCase.NEW, "initiating"), + ("existing", ExistingCase.EXISTING, "responding"), + ): + draft, _ = FilingDraft.objects.get_or_create( + user=user, jurisdiction=jurisdiction, existing_case=existing_case + ) + for metric in ("started", "review"): + event, _ = UsageEvent.objects.get_or_create( + draft=draft, + metric=metric, + operation="staff_demo", + defaults={ + "occurred_at": occurred_at, + "dimensions": { + "filing_kind": kind, + "case_type": "other_or_unknown", + "filer_side": side, + "filing_for": "self", + "zip_code": "unknown", + "usage_frequency": "first", + }, + }, + ) + count_event(event.pk) + self.stdout.write( + "Synthetic local accounts, drafts, and aggregates ready. No documents or court accounts created." + ) + self.stdout.write(f"For new fixtures, use report dates {start} through {end} and monthly grouping.") + self.stdout.write("Existing fixtures are unchanged; repeated runs do not duplicate counts.") diff --git a/efile_app/efile/middleware.py b/efile_app/efile/middleware.py index eef8cf14..178a8a75 100644 --- a/efile_app/efile/middleware.py +++ b/efile_app/efile/middleware.py @@ -13,6 +13,8 @@ class DraftIdentityMiddleware(MiddlewareMixin): """Validate named drafts before views run and preserve them in redirects.""" def process_view(self, request, view_func, view_args, view_kwargs): + if request.resolver_match.namespace == "litefile_staff": + return None statuses = (FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR) if request.resolver_match.url_name == "submit_final_filing": statuses = (*statuses, FilingDraft.Status.SUBMITTING) diff --git a/efile_app/efile/migrations/0031_staff_privacy_analytics.py b/efile_app/efile/migrations/0031_staff_privacy_analytics.py new file mode 100644 index 00000000..4b3cd8b9 --- /dev/null +++ b/efile_app/efile/migrations/0031_staff_privacy_analytics.py @@ -0,0 +1,164 @@ +# Generated by Django 5.2.17 on 2026-10-02 19:39 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('efile', '0030_document_type_confirmed'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userprofile', + options={'verbose_name': 'User profile', 'verbose_name_plural': 'User profiles'}, + ), + migrations.CreateModel( + name='StaffLoginThrottle', + fields=[ + ('key', models.CharField(max_length=64, primary_key=True, serialize=False)), + ('failures', models.PositiveIntegerField(default=0)), + ('window_started', models.DateTimeField()), + ], + ), + migrations.AddField( + model_name='filingdocument', + name='upload_has_form_fields', + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name='filingdraft', + name='deletion_pending', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='filingdraft', + name='submission_operation', + field=models.UUIDField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='userprofile', + name='analytics_excluded', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='PrivacyRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reference', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('jurisdiction', models.CharField(max_length=40)), + ('account_wide', models.BooleanField(default=False)), + ('draft_ids', models.JSONField(default=list)), + ('status', models.CharField(choices=[('received', 'Received'), ('verified', 'Verified'), ('processing', 'Processing'), ('attention', 'Needs attention'), ('completed', 'Live-system deletion completed')], default='received', max_length=20)), + ('verified_at', models.DateTimeField(null=True)), + ('object_keys', models.JSONField(default=list)), + ('deleted_keys', models.JSONField(default=list)), + ('counts', models.JSONField(default=dict)), + ('outcome', models.CharField(blank=True, max_length=40)), + ('external_cleanup_pending', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('completed_at', models.DateTimeField(null=True)), + ('operator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='handled_privacy_requests', to=settings.AUTH_USER_MODEL)), + ('target', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='privacy_requests', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='StaffAudit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reference', models.UUIDField(null=True)), + ('jurisdiction', models.CharField(max_length=40)), + ('action', models.CharField(max_length=40)), + ('outcome', models.CharField(max_length=40)), + ('counts', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('operator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UsageCounter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('day', models.DateField()), + ('granularity', models.CharField(default='day', max_length=5)), + ('jurisdiction', models.CharField(max_length=40)), + ('metric', models.CharField(max_length=40)), + ('filing_kind', models.CharField(max_length=10)), + ('dimension', models.CharField(default='all', max_length=40)), + ('value', models.CharField(default='all', max_length=80)), + ('count', models.PositiveBigIntegerField(default=0)), + ('contributors', models.PositiveBigIntegerField(default=0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'indexes': [models.Index(fields=['jurisdiction', 'day'], name='usage_scope_day')], + 'constraints': [models.UniqueConstraint(fields=('day', 'granularity', 'jurisdiction', 'metric', 'filing_kind', 'dimension', 'value'), name='usage_counter_bucket')], + }, + ), + migrations.CreateModel( + name='StaffRoleGrant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('jurisdiction', models.CharField(max_length=40)), + ('role', models.CharField(choices=[('accounts', 'Account management'), ('analytics', 'Aggregate reporting')], max_length=20)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff_roles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('user', 'jurisdiction', 'role'), name='staff_role_scope')], + }, + ), + migrations.CreateModel( + name='StoredUpload', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(db_index=True, max_length=1024)), + ('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stored_uploads', to='efile.filingdraft')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('draft', 'key'), name='stored_upload_owner')], + }, + ), + migrations.CreateModel( + name='UsageContributor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('counter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='efile.usagecounter')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('user', 'counter'), name='usage_contributor_once')], + }, + ), + migrations.CreateModel( + name='UsageEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('metric', models.CharField(max_length=40)), + ('operation', models.CharField(default='once', max_length=80)), + ('dimensions', models.JSONField(default=dict)), + ('occurred_at', models.DateTimeField()), + ('counted_at', models.DateTimeField(null=True)), + ('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='efile.filingdraft')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('draft', 'metric', 'operation'), name='usage_event_once')], + }, + ), + migrations.CreateModel( + name='UsageMatter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('jurisdiction', models.CharField(max_length=40)), + ('identity', models.CharField(max_length=64)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('user', 'jurisdiction', 'identity'), name='usage_matter_once')], + }, + ), + ] diff --git a/efile_app/efile/migrations/0032_inventory_owned_uploads.py b/efile_app/efile/migrations/0032_inventory_owned_uploads.py new file mode 100644 index 00000000..4340a68e --- /dev/null +++ b/efile_app/efile/migrations/0032_inventory_owned_uploads.py @@ -0,0 +1,38 @@ +"""Inventory current and historical application-owned objects without S3 access.""" + +from django.db import migrations + + +def inventory_uploads(apps, schema_editor): + Document = apps.get_model("efile", "FilingDocument") + Draft = apps.get_model("efile", "FilingDraft") + Upload = apps.get_model("efile", "StoredUpload") + alias = schema_editor.connection.alias + batch = [] + + def add(draft_id, keys): + for key in keys: + if isinstance(key, str) and key: + batch.append(Upload(draft_id=draft_id, key=key)) + if len(batch) >= 1000: + Upload.objects.using(alias).bulk_create(batch, ignore_conflicts=True) + batch.clear() + + for draft_id, current, original in Document.objects.using(alias).values_list("draft_id", "s3_key", "original_s3_key").iterator(): + add(draft_id, [current, original]) + for draft_id, snapshot in Draft.objects.using(alias).values_list("pk", "submission_snapshot").iterator(): + if not isinstance(snapshot, dict): + continue + documents = snapshot.get("documents", []) + if not isinstance(documents, list): + continue + for document in documents: + if isinstance(document, dict): + add(draft_id, [document.get("s3_key"), document.get("original_s3_key")]) + if batch: + Upload.objects.using(alias).bulk_create(batch, ignore_conflicts=True) + + +class Migration(migrations.Migration): + dependencies = [("efile", "0031_staff_privacy_analytics")] + operations = [migrations.RunPython(inventory_uploads, migrations.RunPython.noop)] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 0bf66ed2..d725d2c8 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -31,14 +31,15 @@ class UserProfile(AbstractUser): # differ -- FilingDraft.ai_assistance_opted_out is what the worker reads -- # so this is the value a new draft is born with, not a lock on it. ai_assistance_opted_out = models.BooleanField(default=False) + analytics_excluded = models.BooleanField(default=False) # Timestamps created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: - verbose_name = "User Profile" - verbose_name_plural = "User Profiles" + verbose_name = "User profile" + verbose_name_plural = "User profiles" constraints = [ models.UniqueConstraint( fields=["tyler_jurisdiction", "tyler_username"], @@ -280,6 +281,8 @@ class Status(models.TextChoices): submission_response = models.JSONField(default=dict, blank=True) submitted_at = models.DateTimeField(blank=True, null=True) + submission_operation = models.UUIDField(null=True, blank=True, editable=False) + deletion_pending = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -323,12 +326,17 @@ def mark_submitted(self, response_data): "updated_at", ] ) + from efile.services.analytics import record_event, record_matter + + record_event(self, "transmitted") + record_matter(self) def mark_error(self, response_data): self.status = self.Status.ERROR self.submission_response = response_data or {} self.save(update_fields=["status", "submission_response", "updated_at"]) + @transaction.atomic def save(self, *args, **kwargs): update_fields = kwargs.get("update_fields") if self.pk and (update_fields is None or {"filing_type_code", "filing_type_name"}.intersection(update_fields)): @@ -358,6 +366,7 @@ class Role(models.TextChoices): public_url = models.URLField(max_length=2048, blank=True) # Originals are private recovery copies; only s3_key is sent to the court. original_s3_key = models.CharField(max_length=1024, blank=True) + upload_has_form_fields = models.BooleanField(null=True, blank=True) preparation = models.CharField( max_length=30, blank=True, @@ -544,6 +553,10 @@ def __str__(self): display_name = " ".join(part for part in [self.first_name, self.middle_name, self.last_name] if part) return display_name or self.organization_name or f"{self.role} for draft #{self.draft_id}" + @transaction.atomic + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + class InterviewHandoff(models.Model): """Authenticated source receipt, retained independently of browser sessions.""" @@ -622,3 +635,16 @@ def exists_for(cls, email, jurisdiction): @classmethod def forget(cls, email, jurisdiction): cls.objects.filter(email=email.strip().lower(), jurisdiction=jurisdiction).delete() + + +from efile.staff_models import ( # noqa: E402,F401 + PrivacyRequest, + StaffAudit, + StaffLoginThrottle, + StaffRoleGrant, + StoredUpload, + UsageContributor, + UsageCounter, + UsageEvent, + UsageMatter, +) diff --git a/efile_app/efile/services/analytics.py b/efile_app/efile/services/analytics.py new file mode 100644 index 00000000..19579b1a --- /dev/null +++ b/efile_app/efile/services/analytics.py @@ -0,0 +1,266 @@ +"""Workflow-only outbox and marginal counters; no persistent user identifiers.""" + +import hashlib +import logging +import re +from datetime import UTC, timedelta + +from django.conf import settings +from django.db import transaction +from django.db.models import F, Max +from django.utils import timezone + +from efile.models import FilingDraft, UsageContributor, UsageCounter, UsageEvent, UsageMatter +from efile.party_sides import PartySide, side_for_party_type_name +from efile.services.document_checklists import _find_match +from efile.utils.config_loader import config_loader +from efile.workflow import ExistingCase + +logger = logging.getLogger(__name__) +METRICS = frozenset( + { + "started", + "review", + "submission_attempt", + "transmitted", + "submission_error", + "document_uploaded", + "matter_first_used", + } +) +DIMENSIONS = frozenset( + {"case_type", "filer_side", "filing_for", "zip_code", "preparation", "original_pdf_state", "usage_frequency"} +) + + +def eligible(draft): + return bool( + settings.LITEFILE_ANALYTICS_ENABLED + and not settings.DEBUG + and not getattr(settings, "EFSP_TEST_DOCUMENT_URL", "") + and draft.user_id + and not draft.user.is_staff + and not draft.user.is_superuser + and not draft.user.analytics_excluded + and draft.jurisdiction in config_loader.get_available_jurisdictions() + ) + + +def snapshot(draft, *, document=None): + """All labels come from bounded configuration/enums, never supplied free text.""" + config = config_loader.load_jurisdiction_config(draft.jurisdiction) + matched = _find_match(config.get("case_types", {}), draft.case_type_name) + filer = draft.parties.filter(role="filer").first() + side = (filer.party_side or side_for_party_type_name(filer.party_type_name)) if filer else "" + zip_code = filer.zip_code.strip() if filer else "" + prior = draft.user.filing_drafts.filter(status="submitted").exclude(pk=draft.pk).count() + data = { + "case_type": matched[0] if matched else "other_or_unknown", + "filer_side": side if side in PartySide else "unknown", + "filing_for": ("self" if filer.party_type else "someone_else") if filer else "unknown", + "zip_code": zip_code[:5] if re.fullmatch(r"[0-9]{5}(?:-[0-9]{4})?", zip_code) else "unknown", + "usage_frequency": "first" if prior == 0 else "2_to_5" if prior < 5 else "6_or_more", + } + if document is not None: + data = { + "preparation": document.preparation + if document.preparation in {"unchanged", "flattened", "converted", "converted_flattened"} + else "unknown" + } + data["original_pdf_state"] = ( + "fillable" + if document.upload_has_form_fields is True + else "no_form_fields" + if document.upload_has_form_fields is False + else "not_pdf_or_unknown" + ) + return data + + +@transaction.atomic +def _queue_event(draft, metric, operation, document=None): + locked = FilingDraft.objects.select_for_update().get(pk=draft.pk) + if ( + locked.deletion_pending + or UsageEvent.objects.filter(draft=locked, metric=metric, operation=str(operation)).exists() + ): + return + dimensions = snapshot(locked, document=document) + dimensions["filing_kind"] = {ExistingCase.NEW: "new", ExistingCase.EXISTING: "existing"}.get( + locked.existing_case, "unknown" + ) + UsageEvent.objects.create( + draft=locked, metric=metric, operation=str(operation), dimensions=dimensions, occurred_at=timezone.now() + ) + + +def record_event(draft, metric, *, operation="once", document=None): + """A collection failure must not stop a filing. Outbox retries are idempotent.""" + if metric not in METRICS: + raise ValueError("Unknown usage metric") + try: + if eligible(draft): + _queue_event(draft, metric, operation, document) + except Exception: + # Fixed message: exceptions can contain SQL parameters or case details. + logger.error("Usage event could not be queued; collection is degraded") + + +@transaction.atomic +def count_event(event_id): + event = ( + UsageEvent.objects.select_for_update(of=("self",)) + .select_related("draft__user") + .filter(pk=event_id, counted_at__isnull=True) + .first() + ) + if event is None: + return + values = dict(event.dimensions) + if set(values) - DIMENSIONS - {"filing_kind"} or event.metric not in METRICS: + raise ValueError("Disallowed usage data") + kind = values.pop("filing_kind") + if kind not in {"new", "existing", "unknown"}: + raise ValueError("Disallowed filing kind") + allowed = { + "case_type": set(config_loader.load_jurisdiction_config(event.draft.jurisdiction).get("case_types", {})) + | {"other_or_unknown"}, + "filer_side": {str(side) for side in PartySide} | {"unknown"}, + "filing_for": {"self", "someone_else", "unknown"}, + "preparation": {"unchanged", "flattened", "converted", "converted_flattened", "unknown"}, + "original_pdf_state": {"fillable", "no_form_fields", "not_pdf_or_unknown"}, + "usage_frequency": {"first", "2_to_5", "6_or_more"}, + } + for dimension, value in values.items(): + if not isinstance(value, str) or len(value) > 80: + raise ValueError("Disallowed usage value") + if dimension == "zip_code": + valid = value == "unknown" or re.fullmatch(r"[0-9]{5}", value) + else: + valid = value in allowed[dimension] + if not valid: + raise ValueError("Disallowed usage value") + day = event.occurred_at.astimezone(UTC).date() + cells = [(day, "day", "all", "all"), (day.replace(day=1), "month", "all", "all")] + cells.extend((day.replace(day=1), "month", dimension, value) for dimension, value in sorted(values.items())) + for period, granularity, dimension, value in cells: + counter, _ = UsageCounter.objects.get_or_create( + day=period, + granularity=granularity, + jurisdiction=event.draft.jurisdiction, + metric=event.metric, + filing_kind=kind, + dimension=dimension, + value=value, + ) + _, new_contributor = UsageContributor.objects.get_or_create(counter=counter, user_id=event.draft.user_id) + UsageCounter.objects.filter(pk=counter.pk).update( + count=F("count") + 1, contributors=F("contributors") + int(new_contributor), updated_at=timezone.now() + ) + event.counted_at = timezone.now() + # Keep only the operational deduplication marker after successful rollup. + event.dimensions = {} + event.save(update_fields=["counted_at", "dimensions"]) + + +def drain_events(draft_ids=None, *, limit=1000): + events = UsageEvent.objects.filter(counted_at__isnull=True).order_by("pk") + if draft_ids is not None: + events = events.filter(draft_id__in=draft_ids) + for event_id in events.values_list("pk", flat=True)[:limit]: + count_event(event_id) + + +def record_matter(draft): + try: + if not eligible(draft): + return + with transaction.atomic(): + # Operational aliases connect a plan's new-case filing to later + # filings in its court case, including filings from another plan. + aliases = [] + if draft.plan_id: + aliases.append(f"plan:{draft.plan_id}") + case_id = draft.previous_case_id or (draft.plan.case_tracking_id if draft.plan_id else "") + if case_id: + aliases.append(f"case:{draft.court_code}:{case_id}") + if not aliases: + aliases = [f"draft:{draft.pk}"] + created = [] + for identity in sorted(hashlib.sha256(alias.encode()).hexdigest() for alias in aliases): + _, new = UsageMatter.objects.get_or_create( + user=draft.user, jurisdiction=draft.jurisdiction, identity=identity + ) + created.append(new) + if all(created): + _queue_event(draft, "matter_first_used", "once") + except Exception: + logger.error("Matter usage could not be queued; collection is degraded") + + +def report_rows(jurisdictions, start, end, *, grouping="month", dimension="all"): + if dimension not in DIMENSIONS | {"all"} or grouping not in {"day", "month"}: + raise ValueError("Invalid report breakdown") + # ZIP and other detailed breakdowns use full UTC months only. + if dimension != "all" and (grouping != "month" or start.day != 1 or (end + timedelta(days=1)).day != 1): + raise ValueError("Detailed reports require complete calendar months.") + if grouping == "month" and (start.day != 1 or (end + timedelta(days=1)).day != 1): + raise ValueError("Monthly reports require complete calendar months.") + counters = UsageCounter.objects.filter( + jurisdiction__in=jurisdictions, day__gte=start, day__lte=end, granularity=grouping + ) + buckets = {} + for row in counters.order_by("day", "jurisdiction", "metric", "filing_kind", "dimension", "value"): + period = row.day.replace(day=1) if grouping == "month" else row.day + key = (period, row.jurisdiction, row.metric) + buckets.setdefault(key, []).append(row) + result = [] + threshold = max(5, settings.LITEFILE_ANALYTICS_MIN_CONTRIBUTORS) + for (period, jurisdiction, metric), rows in sorted(buckets.items()): + # Suppress the entire requested breakdown, including all new/existing + # subtotals and labels. No partial detailed breakdown can be subtracted + # from a core total to reveal a hidden value. + rows = [row for row in rows if row.dimension == dimension] + if not rows: + continue + suppressed = any(row.contributors < threshold for row in rows) + if suppressed: + result.append( + { + "period": period, + "jurisdiction": jurisdiction, + "metric": metric, + "filing_kind": "suppressed", + "dimension": dimension, + "value": "suppressed", + "count": "suppressed", + } + ) + continue + grouped = {} + for row in rows: + if row.dimension == dimension: + key = (row.filing_kind, row.value) + grouped[key] = grouped.get(key, 0) + row.count + for (kind, value), count in sorted(grouped.items()): + result.append( + { + "period": period, + "jurisdiction": jurisdiction, + "metric": metric, + "filing_kind": kind, + "dimension": dimension, + "value": value, + "count": count, + } + ) + return result + + +def collection_health(jurisdictions): + counters = UsageCounter.objects.filter(jurisdiction__in=jurisdictions) + return { + "first_counter": counters.filter(granularity="day").order_by("day").values_list("day", flat=True).first(), + "freshness": counters.aggregate(latest=Max("updated_at"))["latest"], + "pending": UsageEvent.objects.filter(counted_at__isnull=True, draft__jurisdiction__in=jurisdictions).count(), + } diff --git a/efile_app/efile/services/document_extractions.py b/efile_app/efile/services/document_extractions.py index ae4785a3..31cec426 100644 --- a/efile_app/efile/services/document_extractions.py +++ b/efile_app/efile/services/document_extractions.py @@ -492,15 +492,22 @@ def claim_next_extraction(stale_after_minutes=15): completed_at=now, updated_at=now, ) - candidates = DocumentExtraction.objects.filter(attempts__lt=max_attempts).filter( - Q(status=DocumentExtraction.Status.PENDING, available_at__lte=now) | expired - ) + candidates = DocumentExtraction.objects.filter( + attempts__lt=max_attempts, document__draft__deletion_pending=False + ).filter(Q(status=DocumentExtraction.Status.PENDING, available_at__lte=now) | expired) with transaction.atomic(): + job = candidates.order_by("created_at").first() + if job is None: + return None + # Match deletion and completion lock order: draft, then extraction job. + drafts = FilingDraft.objects.filter(pk=job.document.draft_id, deletion_pending=False) if connection.features.has_select_for_update_skip_locked: - candidates = candidates.select_for_update(skip_locked=True) + drafts = drafts.select_for_update(skip_locked=True) else: - candidates = candidates.select_for_update() - job = candidates.order_by("created_at").first() + drafts = drafts.select_for_update() + if drafts.first() is None: + return None + job = candidates.select_for_update().filter(pk=job.pk).first() if job is None: return None job.status = DocumentExtraction.Status.PROCESSING @@ -523,6 +530,7 @@ def _current_claim(job_id, claim_token): claim_token=claim_token, status=DocumentExtraction.Status.PROCESSING, lease_expires_at__gt=timezone.now(), + document__draft__deletion_pending=False, ) diff --git a/efile_app/efile/services/document_preparation.py b/efile_app/efile/services/document_preparation.py index 2b5b1141..6ae1cc1c 100644 --- a/efile_app/efile/services/document_preparation.py +++ b/efile_app/efile/services/document_preparation.py @@ -33,6 +33,7 @@ class PreparedDocument: content: bytes filename: str operation: str + upload_has_form_fields: bool | None = None def requires_flattening(jurisdiction): @@ -213,6 +214,7 @@ def prepare_document(uploaded_file, jurisdiction): if not content or len(content) > settings.MAX_FILE_SIZE: raise PreparationError("Choose a file that is not empty and is up to 10 MB.") operation = "unchanged" + upload_has_form_fields = bool(inspect_pdf(content)[1]) if suffix == ".pdf" else None filename = uploaded_file.name if suffix in {".doc", ".docx"}: _word_format(content, suffix) @@ -230,7 +232,7 @@ def prepare_document(uploaded_file, jurisdiction): operation = "converted_flattened" if operation == "converted" else "flattened" else: inspect_pdf(content) - return PreparedDocument(content, filename, operation) + return PreparedDocument(content, filename, operation, upload_has_form_fields) def store_prepared_document(handler, uploaded_file, jurisdiction, role, *, keys, metadata=None): @@ -254,6 +256,7 @@ def store_prepared_document(handler, uploaded_file, jurisdiction, role, *, keys, "original_filename": uploaded_file.name[:255], "original_s3_key": original_key, "preparation": prepared.operation, + "upload_has_form_fields": prepared.upload_has_form_fields, "preparation_reviewed_at": None, "size": len(prepared.content), "content_type": "application/pdf", diff --git a/efile_app/efile/services/document_uploads.py b/efile_app/efile/services/document_uploads.py index fccbb51e..39e6dd49 100644 --- a/efile_app/efile/services/document_uploads.py +++ b/efile_app/efile/services/document_uploads.py @@ -5,7 +5,7 @@ from django.db import transaction from django.db.models import Max -from efile.models import FilingDocument, FilingDraft +from efile.models import FilingDocument, FilingDraft, StoredUpload from efile.services.document_extractions import queue_document_extraction from efile.services.document_preparation import ( PreparationError, @@ -23,46 +23,56 @@ def upload_files(draft, uploaded_files, jurisdiction, *, current_step=WorkflowStepKey.UPLOAD_DOCUMENTS): """Prepare and store a whole batch, then queue analysis of the filing copy.""" handler = S3UploadHandler() - if not handler._ensure_initialized(): - raise ValueError("Document storage is not configured. Please try again later.") keys = [] - try: - # Prepare the entire batch before changing the durable draft. - prepared = [] - for file in uploaded_files: - try: - prepared.append(store_prepared_document(handler, file, jurisdiction, "document", keys=keys)) - except ValueError as error: - raise ValueError(f"{file.name}: {error}") from error - with transaction.atomic(): - draft = FilingDraft.objects.select_for_update().get(pk=draft.pk) - if draft.status not in ACTIVE_DRAFT_STATUSES: - raise ValueError("This filing is no longer available to edit.") - has_lead = draft.documents.filter(role=FilingDocument.Role.LEAD).exists() - highest = draft.documents.filter(role=FilingDocument.Role.SUPPORTING).aggregate(order=Max("sort_order"))[ - "order" - ] - order = 0 if highest is None else highest + 1 - for values in prepared: - is_lead = not has_lead - document = FilingDocument.objects.create( - draft=draft, - role=FilingDocument.Role.LEAD if is_lead else FilingDocument.Role.SUPPORTING, - sort_order=0 if is_lead else order, - **values, - ) - if is_lead: - has_lead = True - draft.extracted_guesses = {} - transaction.on_commit(partial(queue_document_extraction, document), robust=True) - else: - order += 1 - draft.current_step = str(current_step) - invalidate_fee_quote(draft, save=False) - draft.save() - except Exception: - cleanup_uploads(handler, keys) - raise + failure = None + # Hold the draft lock BEFORE sending bytes to storage. Deletion either + # includes the completed batch or freezes it before any upload can begin. + with transaction.atomic(): + draft = FilingDraft.objects.select_for_update().get(pk=draft.pk) + if draft.deletion_pending or draft.status not in ACTIVE_DRAFT_STATUSES: + raise ValueError("This filing is no longer available to edit.") + try: + with transaction.atomic(): + if not handler._ensure_initialized(): + raise ValueError("Document storage is not configured. Please try again later.") + prepared = [] + for file in uploaded_files: + try: + prepared.append(store_prepared_document(handler, file, jurisdiction, "document", keys=keys)) + except ValueError as error: + raise ValueError(f"{file.name}: {error}") from error + has_lead = draft.documents.filter(role=FilingDocument.Role.LEAD).exists() + highest = draft.documents.filter(role=FilingDocument.Role.SUPPORTING).aggregate( + order=Max("sort_order") + )["order"] + order = 0 if highest is None else highest + 1 + for values in prepared: + is_lead = not has_lead + document = FilingDocument.objects.create( + draft=draft, + role=FilingDocument.Role.LEAD if is_lead else FilingDocument.Role.SUPPORTING, + sort_order=0 if is_lead else order, + **values, + ) + if is_lead: + has_lead = True + draft.extracted_guesses = {} + transaction.on_commit(partial(queue_document_extraction, document), robust=True) + else: + order += 1 + draft.current_step = str(current_step) + invalidate_fee_quote(draft, save=False) + draft.save() + except Exception as error: + cleanup_uploads(handler, keys) + # Commit ownership even after the batch savepoint rolled back. A + # failed cleanup must remain discoverable by a later privacy request. + StoredUpload.objects.bulk_create( + [StoredUpload(draft=draft, key=key) for key in set(keys)], ignore_conflicts=True + ) + failure = error + if failure is not None: + raise failure return read_upload_data(draft) @@ -75,7 +85,7 @@ def prepare_stored_documents(draft, handler): documents = list(locked.documents.filter(preparation="")) if not documents: return - if locked.status not in ACTIVE_DRAFT_STATUSES: + if locked.deletion_pending or locked.status not in ACTIVE_DRAFT_STATUSES: raise PreparationError("This filing is no longer available to edit.") if not handler._ensure_initialized() or handler.s3_client is None: raise PreparationUnavailable("Document storage is unavailable. Please try again later.") diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index c8db8995..27ca3381 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -25,7 +25,7 @@ def active_drafts_for(user, *, jurisdiction: str | None = None) -> QuerySet[FilingDraft]: """Return active drafts owned by ``user``, newest first.""" - drafts = FilingDraft.objects.filter(user=user, status__in=ACTIVE_DRAFT_STATUSES) + drafts = FilingDraft.objects.filter(user=user, status__in=ACTIVE_DRAFT_STATUSES, deletion_pending=False) if jurisdiction is not None: drafts = drafts.filter(jurisdiction=jurisdiction) return drafts.order_by("-updated_at") @@ -40,7 +40,7 @@ def get_active_draft( ) -> FilingDraft | None: """Get an owned draft by ID, or the user's most recent one, within ``statuses``.""" - drafts = FilingDraft.objects.filter(user=user, status__in=statuses) + drafts = FilingDraft.objects.filter(user=user, status__in=statuses, deletion_pending=False) if jurisdiction is not None: drafts = drafts.filter(jurisdiction=jurisdiction) drafts = drafts.order_by("-updated_at") @@ -64,6 +64,16 @@ def create_draft( if not jurisdiction: raise ValueError("A filing draft must have a jurisdiction") + from efile.models import PrivacyRequest, UserProfile + + UserProfile.objects.select_for_update().get(pk=user.pk) + if ( + user.is_staff + or PrivacyRequest.objects.filter(target=user, status="processing").exists() + or user.filing_drafts.filter(deletion_pending=True).exists() + ): + raise ValueError("This account is unavailable for filing.") + return FilingDraft.objects.create( user=user, jurisdiction=jurisdiction, diff --git a/efile_app/efile/services/filing_availability.py b/efile_app/efile/services/filing_availability.py index 6f66e822..c1515e04 100644 --- a/efile_app/efile/services/filing_availability.py +++ b/efile_app/efile/services/filing_availability.py @@ -111,6 +111,8 @@ def _unavailable_message(availabilities, *, case_category="", case_type="", fili def draft_unavailable_message(draft): + if draft.deletion_pending: + return "A verified data deletion is in progress for this filing." availabilities = _court_availability(draft.jurisdiction, draft.court_code) # Most courts have no filing-type rule; skip the document query for them. filing_types = ( diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py index a0cd1516..bfb1775e 100644 --- a/efile_app/efile/services/handoff.py +++ b/efile_app/efile/services/handoff.py @@ -326,6 +326,7 @@ def populate(draft, payload, uploads): public_url=uploaded["url"], original_s3_key=uploaded.get("original_s3_key", ""), preparation=uploaded.get("preparation", ""), + upload_has_form_fields=uploaded.get("upload_has_form_fields"), ) order[document["role"]] += 1 record(draft, f"documents.{row.pk}", "source_suggestion", document) @@ -558,6 +559,9 @@ def full_snapshot(draft): @transaction.atomic def create_correction(draft, detail, fields): + from efile.models import UserProfile + + UserProfile.objects.select_for_update().get(pk=draft.user_id) original = FilingDraft.objects.select_for_update().get(pk=draft.pk) if original.status != FilingDraft.Status.SUBMITTED or detail.get("status", "").strip().lower() not in { "rejected", diff --git a/efile_app/efile/services/privacy.py b/efile_app/efile/services/privacy.py new file mode 100644 index 00000000..07b53e17 --- /dev/null +++ b/efile_app/efile/services/privacy.py @@ -0,0 +1,309 @@ +"""Verified, scoped, resumable deletion of live LITEFile data.""" + +from typing import cast + +from django.conf import settings +from django.contrib.sessions.backends.db import SessionStore +from django.contrib.sessions.models import Session +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from efile.models import ( + DocumentExtraction, + FilingDocument, + FilingDraft, + PendingActivation, + PrivacyRequest, + StaffAudit, + StaffRoleGrant, + StoredUpload, + UserProfile, +) +from efile.services.analytics import drain_events +from efile.staff_security import require_scope +from efile.utils.s3_upload_handler import S3UploadHandler + + +def audit(operator, jurisdiction, action, outcome, *, reference=None, counts=None): + StaffAudit.objects.create( + operator=operator, + jurisdiction=jurisdiction, + action=action, + outcome=outcome, + reference=reference, + counts=counts or {}, + ) + + +def account_sessions(user, draft_ids=None): + """Decode privately for matching; never send session payloads to staff/logs.""" + ids = {str(value) for value in draft_ids} if draft_ids is not None else None + matches = [] + for session in Session.objects.all().iterator(): + data = SessionStore().decode(cast(Session, session).session_data) + if str(data.get("_auth_user_id", "")) != str(user.pk): + continue + if ids is not None and not any( + str(data.get(key, "")) in ids for key in ("filing_draft_id", "last_submitted_filing_draft_id") + ): + # Legacy sessions have complete case/upload copies with no draft ID; + # revoke them too when removing one of this account's filings. + if not ("case_data" in data or "upload_data" in data): + continue + matches.append(session) + return matches + + +def selected_drafts(request): + if request.target_id is None: + return FilingDraft.objects.none() + drafts = FilingDraft.objects.filter(user_id=request.target_id, jurisdiction=request.jurisdiction) + return drafts if request.account_wide else drafts.filter(pk__in=request.draft_ids) + + +def clear_staff_lookup_cache(user, draft_ids, account_wide): + """Remove temporary staff search locators while preserving their login sessions.""" + draft_ids = {str(value) for value in draft_ids} + emails = {value.casefold() for value in (user.email, user.account_email) if value} + store = SessionStore() + for session in Session.objects.all().iterator(): + data = store.decode(cast(Session, session).session_data) + lookup = data.get("staff_lookup") + if not isinstance(lookup, dict) or lookup.get("jurisdiction") != user.tyler_jurisdiction: + continue + matches = str(lookup.get("draft_id")) in draft_ids + if account_wide: + matches = ( + matches + or str(lookup.get("account_id")) == str(user.pk) + or str(lookup.get("email", "")).casefold() in emails + ) + if matches: + data.pop("staff_lookup") + Session.objects.filter(pk=cast(Session, session).pk).update(session_data=store.encode(data)) + + +def preview(request): + user = request.target + if user is None: + return {"counts": {}, "blockers": ["account_missing"], "keys": [], "draft_ids": []} + drafts = selected_drafts(request) + ids = list(drafts.values_list("pk", flat=True)) + documents = FilingDocument.objects.filter(draft_id__in=ids) + keys = sorted({key for pair in documents.values_list("s3_key", "original_s3_key") for key in pair if key}) + keys = set(keys) | set(StoredUpload.objects.filter(draft_id__in=ids).values_list("key", flat=True)) + for draft in drafts: + for document in (draft.submission_snapshot or {}).get("documents", []): + if isinstance(document, dict): + keys.update( + key + for key in (document.get("s3_key"), document.get("original_s3_key")) + if isinstance(key, str) and key + ) + keys = sorted(keys) + blockers = [] + if user.is_staff or user.is_superuser: + blockers.append("staff_account") + if user.tyler_jurisdiction != request.jurisdiction: + blockers.append("jurisdiction_mismatch") + if not request.account_wide and set(ids) != set(request.draft_ids): + blockers.append("scope_changed") + if request.account_wide and any( + relation.exclude(jurisdiction=request.jurisdiction).exists() + for relation in (user.filing_drafts, user.filing_plans, user.archived_cases) + ): + blockers.append("cross_jurisdiction_data") + if drafts.filter(status__in=["submitting", "error"]).exists(): + blockers.append("submission_requires_reconciliation") + if DocumentExtraction.objects.filter(document__draft_id__in=ids, status="processing").exists(): + blockers.append("extraction_worker_in_flight") + if FilingDraft.objects.filter(correction_of_id__in=ids).exclude(pk__in=ids).exists(): + blockers.append("corrections_outside_scope") + shared = FilingDocument.objects.filter(Q(s3_key__in=keys) | Q(original_s3_key__in=keys)).exclude(draft_id__in=ids) + shared_keys = {key for pair in shared.values_list("s3_key", "original_s3_key") for key in pair if key in keys} + shared_keys.update( + StoredUpload.objects.filter(key__in=keys).exclude(draft_id__in=ids).values_list("key", flat=True) + ) + if shared_keys: + blockers.append("shared_objects") + counts = { + "accounts": int(request.account_wide), + "drafts": len(ids), + "documents": documents.count(), + "parties": sum(draft.parties.count() for draft in drafts), + "extractions": DocumentExtraction.objects.filter(document__draft_id__in=ids).count(), + "metadata_events": sum(draft.metadata_events.count() for draft in drafts), + "handoffs": sum(int(hasattr(draft, "handoff")) for draft in drafts), + "plans": user.filing_plans.count() if request.account_wide else 0, + "archived_cases": user.archived_cases.count() if request.account_wide else 0, + "objects": len(keys), + "shared_objects": len(shared_keys), + "sessions": len(account_sessions(user, None if request.account_wide else ids)), + } + return {"counts": counts, "blockers": blockers, "keys": keys, "draft_ids": ids} + + +@transaction.atomic +def create_request(operator, target, *, account_wide, draft_ids=()): + require_scope(operator, target.tyler_jurisdiction, StaffRoleGrant.Role.ACCOUNTS) + if target.is_staff or target.is_superuser: + raise ValueError("Staff accounts cannot enter the litigant deletion workflow.") + UserProfile.objects.select_for_update(no_key=True).get(pk=target.pk) + ids = [] if account_wide else sorted(set(draft_ids)) + if not account_wide and ( + not ids or target.filing_drafts.filter(pk__in=ids, jurisdiction=target.tyler_jurisdiction).count() != len(ids) + ): + raise ValueError("Select only drafts owned by this account in this jurisdiction.") + if PrivacyRequest.objects.filter(target=target).exclude(status="completed").exists(): + raise ValueError("This account already has an open request.") + request = PrivacyRequest.objects.create( + target=target, + operator=operator, + jurisdiction=target.tyler_jurisdiction, + account_wide=account_wide, + draft_ids=ids, + ) + audit(operator, request.jurisdiction, "request_received", "received", reference=request.reference) + return request + + +@transaction.atomic +def verify_request(request_id, operator): + request = PrivacyRequest.objects.select_for_update().get(pk=request_id) + require_scope(operator, request.jurisdiction, StaffRoleGrant.Role.ACCOUNTS) + if request.status != "received": + raise ValueError("Only a received request can be verified.") + request.status = PrivacyRequest.Status.VERIFIED + request.verified_at = timezone.now() + request.operator = operator + request.save() + audit(operator, request.jurisdiction, "identity_verified", "verified", reference=request.reference) + + +def process_request(request_id, operator, *, handler=None, expected=None): + """Hold database locks through cleanup; S3 progress commits separately. + + The durable manifest is created before touching S3. Each object completion is + saved independently, so a database rollback never pretends to restore S3. + A failed request keeps frozen drafts and its manifest until a successful retry. + """ + if not settings.LITEFILE_PRIVACY_DELETION_ENABLED: + raise ValueError("Deletion is disabled pending operator policy configuration.") + with transaction.atomic(): + request = PrivacyRequest.objects.select_for_update().get(pk=request_id) + require_scope(operator, request.jurisdiction, StaffRoleGrant.Role.ACCOUNTS) + if request.status == "completed": + return request + if request.verified_at is None: + raise ValueError("Verify identity and authority before processing.") + # Lock the owner before drafts: new draft creation also takes this lock. + # PostgreSQL NO KEY UPDATE permits analytics/submission FK key-share + # checks while serializing owner workflows, avoiding lock inversion. + UserProfile.objects.select_for_update(no_key=True).get(pk=request.target_id) + list(selected_drafts(request).select_for_update()) + plan = preview(request) + if expected is not None and any(expected[key] != plan[key] for key in ("counts", "draft_ids")): + raise ValueError("The deletion scope changed. Review a fresh preview before confirming.") + if plan["blockers"]: + request.status = PrivacyRequest.Status.ATTENTION + request.outcome = plan["blockers"][0] + request.save() + audit( + operator, + request.jurisdiction, + "deletion_deferred", + request.outcome, + reference=request.reference, + counts=plan["counts"], + ) + return request + if request.status != "processing" and not request.object_keys: + request.object_keys = plan["keys"] + request.draft_ids = plan["draft_ids"] + request.counts = plan["counts"] + request.status = PrivacyRequest.Status.PROCESSING + request.outcome = "" + request.operator = operator + request.save() + selected_drafts(request).update(deletion_pending=True) + # Live sessions are revoked at freeze time, including failed attempts. + Session.objects.filter( + pk__in=[s.pk for s in account_sessions(request.target, None if request.account_wide else request.draft_ids)] + ).delete() + + handler = handler or S3UploadHandler() + # Serialize workers on the request. Object progress is durable outside the + # final database deletion transaction; failed keys remain in the manifest. + for key in request.object_keys: + with transaction.atomic(): + request = PrivacyRequest.objects.select_for_update().get(pk=request_id) + if request.status == "completed": + return request + if key in request.deleted_keys: + continue + if ( + FilingDocument.objects.filter(Q(s3_key=key) | Q(original_s3_key=key)) + .exclude(draft_id__in=request.draft_ids) + .exists() + ) or StoredUpload.objects.filter(key=key).exclude(draft_id__in=request.draft_ids).exists(): + result = {"success": False, "outcome": "shared_objects"} + else: + try: + result = handler.erase_file(key) + except Exception: + result = {"success": False} + if not result.get("success"): + request.status = PrivacyRequest.Status.ATTENTION + request.outcome = result.get("outcome", "storage_failed") + request.save() + audit( + operator, + request.jurisdiction, + "deletion_failed", + request.outcome, + reference=request.reference, + counts=request.counts, + ) + return request + request.deleted_keys = [*request.deleted_keys, key] + request.save() + + with transaction.atomic(): + request = PrivacyRequest.objects.select_for_update().get(pk=request_id) + if request.status == "completed": + return request + user = UserProfile.objects.select_for_update(no_key=True).get(pk=request.target_id) + drafts = selected_drafts(request) + list(drafts.select_for_update()) + # Drain eligible outbox events before their operational links disappear. + drain_events(request.draft_ids, limit=1_000_000) + clear_staff_lookup_cache(user, request.draft_ids, request.account_wide) + Session.objects.filter( + pk__in=[s.pk for s in account_sessions(user, None if request.account_wide else request.draft_ids)] + ).delete() + if request.account_wide: + PendingActivation.objects.filter(jurisdiction=request.jurisdiction).filter( + Q(email__iexact=user.email) | Q(email__iexact=user.account_email) + ).delete() + user.delete() + else: + drafts.delete() + # No target identifiers, storage keys, contact data, or payloads survive. + request.target = None + request.draft_ids = [] + request.object_keys = [] + request.deleted_keys = [] + request.status = PrivacyRequest.Status.COMPLETED + request.outcome = "live_system_deleted" + request.completed_at = timezone.now() + request.save() + audit( + operator, + request.jurisdiction, + "deletion_completed", + request.outcome, + reference=request.reference, + counts=request.counts, + ) + return request diff --git a/efile_app/efile/settings_base.py b/efile_app/efile/settings_base.py index 71bb1273..1b2d93a2 100644 --- a/efile_app/efile/settings_base.py +++ b/efile_app/efile/settings_base.py @@ -1,5 +1,6 @@ import json import os +import re from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -33,6 +34,8 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django_otp", + "django_otp.plugins.otp_totp", "efile", "efile.templatetags.md_to_html", "crosswalk_review", @@ -48,6 +51,8 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "django_otp.middleware.OTPMiddleware", + "efile.staff_security.StaffIsolationMiddleware", "efile.middleware.JurisdictionSessionMiddleware", "efile.middleware.DraftIdentityMiddleware", "django.contrib.messages.middleware.MessageMiddleware", @@ -141,6 +146,19 @@ # in deployed filing apps while that corpus is not provisioned outside the image. CROSSWALK_REVIEW_ENABLED = False +# Private staff URL: intentionally absent from public menus and sitemaps. +LITEFILE_STAFF_PATH = os.getenv("LITEFILE_STAFF_PATH", "staff-7c83f0a2").strip("/") +if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{11,79}", LITEFILE_STAFF_PATH): + raise ValueError( + "LITEFILE_STAFF_PATH must be a private URL segment of 12–80 letters, digits, underscores or hyphens." + ) +LITEFILE_STAFF_SESSION_SECONDS = 900 +LITEFILE_ANALYTICS_ENABLED = os.getenv("LITEFILE_ANALYTICS_ENABLED", "false").lower() == "true" +LITEFILE_PRIVACY_DELETION_ENABLED = os.getenv("LITEFILE_PRIVACY_DELETION_ENABLED", "false").lower() == "true" +LITEFILE_ANALYTICS_EXPORT_ENABLED = os.getenv("LITEFILE_ANALYTICS_EXPORT_ENABLED", "false").lower() == "true" +LITEFILE_ANALYTICS_MIN_CONTRIBUTORS = int(os.getenv("LITEFILE_ANALYTICS_MIN_CONTRIBUTORS", "5")) +LITEFILE_STAFF_REQUEST_RETENTION_DAYS = int(os.getenv("LITEFILE_STAFF_REQUEST_RETENTION_DAYS", "90")) + # File Upload Settings DOCUMENT_EXTRACTION_TIMEOUT_SECONDS = int(os.getenv("DOCUMENT_EXTRACTION_TIMEOUT_SECONDS", "600")) DOCUMENT_EXTRACTION_MEMORY_MB = int(os.getenv("DOCUMENT_EXTRACTION_MEMORY_MB", "768")) @@ -174,6 +192,7 @@ LOGGING = { "version": 1, "disable_existing_loggers": False, + "filters": {"staff_redaction": {"()": "efile.staff_logging.StaffLogRedactionFilter"}}, "formatters": { "simple": { "format": "[%(levelname)s] %(asctime)s %(name)s: %(message)s", @@ -183,6 +202,7 @@ "console": { "class": "logging.StreamHandler", "formatter": "simple", + "filters": ["staff_redaction"], } }, "loggers": { diff --git a/efile_app/efile/settings_staging.py b/efile_app/efile/settings_staging.py index 0084a7ea..e2582782 100644 --- a/efile_app/efile/settings_staging.py +++ b/efile_app/efile/settings_staging.py @@ -52,6 +52,7 @@ LOGGING = { "version": 1, "disable_existing_loggers": False, + "filters": {"staff_redaction": {"()": "efile.staff_logging.StaffLogRedactionFilter"}}, "formatters": { "verbose": { "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s", @@ -61,6 +62,7 @@ "console": { "class": "logging.StreamHandler", "formatter": "verbose", + "filters": ["staff_redaction"], }, }, "root": { diff --git a/efile_app/efile/signals.py b/efile_app/efile/signals.py index ff8f9559..2a046004 100644 --- a/efile_app/efile/signals.py +++ b/efile_app/efile/signals.py @@ -16,10 +16,87 @@ def synchronize_deleted_document(sender, instance, **kwargs): # Metadata provenance is recorded for ordinary filing screens as well as the # handoff screen. Source suggestions stay in the receipt when a filer overrides # them. Pure summary synchronization intentionally uses QuerySet.update instead. +from django.contrib.sessions.models import Session # noqa: E402 from django.db.models.signals import post_save, pre_save # noqa: E402 from efile.models import FilingParty # noqa: E402 + +@receiver(pre_save, sender=Session) +def prevent_erased_account_sessions(sender, instance, raw=False, **kwargs): + """Serialize late login/response saves with account deletion and freezing.""" + if raw: + return + from django.contrib.sessions.backends.db import SessionStore + from django.core.exceptions import PermissionDenied + + from efile.models import PrivacyRequest, UserProfile + + data = SessionStore().decode(instance.session_data) + user_id = data.get("_auth_user_id") + if user_id is None: + return + owner = UserProfile.objects.select_for_update().filter(pk=user_id).first() + if ( + owner is None + or PrivacyRequest.objects.filter(target=owner, status="processing").exists() + or owner.filing_drafts.filter(deletion_pending=True).exists() + ): + raise PermissionDenied("This account is unavailable.") + + +@receiver(pre_save, sender=FilingDraft) +@receiver(pre_save, sender=FilingDocument) +@receiver(pre_save, sender=FilingParty) +def prevent_erased_data_writes(sender, instance, raw=False, **kwargs): + if raw: + return + from django.core.exceptions import PermissionDenied + + draft_id = instance.pk if sender is FilingDraft else instance.draft_id + if draft_id: + state = FilingDraft.objects.select_for_update().filter(pk=draft_id).values("deletion_pending").first() + if state is None or state["deletion_pending"]: + raise PermissionDenied("This filing is unavailable.") + elif sender is FilingDraft and instance.user_id: + from efile.models import PrivacyRequest, UserProfile + + owner = UserProfile.objects.select_for_update().filter(pk=instance.user_id).first() + if ( + owner is None + or PrivacyRequest.objects.filter(target=owner, status="processing").exists() + or owner.filing_drafts.filter(deletion_pending=True).exists() + ): + raise PermissionDenied("This account is unavailable.") + + +@receiver(post_save, sender=FilingDraft) +def collect_draft_usage(sender, instance, created=False, raw=False, update_fields=None, **kwargs): + if raw or not instance.user_id: + return + from efile.services.analytics import record_event + + if not created and (update_fields is None or "user" not in update_fields): + return + record_event(instance, "started") + for document in instance.documents.exclude(preparation=""): + record_event(instance, "document_uploaded", operation=f"document:{document.pk}", document=document) + + +@receiver(post_save, sender=FilingDocument) +def collect_document_usage(sender, instance, raw=False, created=False, **kwargs): + if not raw: + from efile.models import StoredUpload + + for key in (instance.s3_key, instance.original_s3_key): + if key: + StoredUpload.objects.get_or_create(draft_id=instance.draft_id, key=key) + if not raw and created and instance.preparation: + from efile.services.analytics import record_event + + record_event(instance.draft, "document_uploaded", operation=f"document:{instance.pk}", document=instance) + + _METADATA_FIELDS = { FilingDraft: ("court_code", "case_category_code", "case_type_code", "case_subtype_code"), FilingDocument: ("filing_type_code", "document_type_code", "filing_component_code", "requested_optional_services"), diff --git a/efile_app/efile/staff_admin.py b/efile_app/efile/staff_admin.py new file mode 100644 index 00000000..c1697cc0 --- /dev/null +++ b/efile_app/efile/staff_admin.py @@ -0,0 +1,438 @@ +"""TOTP-protected administration with jurisdiction-scoped purpose-built views.""" + +import csv +import re +from datetime import date, timedelta +from typing import cast + +from django import forms +from django.conf import settings +from django.contrib import admin, messages +from django.contrib.auth.admin import UserAdmin +from django.contrib.sessions.models import Session +from django.core import signing +from django.core.exceptions import PermissionDenied +from django.core.paginator import Paginator +from django.db.models import Q +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import path, reverse +from django.utils import timezone +from django.utils.crypto import salted_hmac +from django.utils.html import format_html +from django_otp.admin import OTPAdminSite +from django_otp.plugins.otp_totp.models import TOTPDevice + +from efile.models import FilingDraft, PrivacyRequest, StaffRoleGrant, UserProfile +from efile.services.analytics import collection_health, report_rows +from efile.services.privacy import account_sessions, audit, create_request, preview, process_request, verify_request +from efile.staff_security import StaffLoginForm, jurisdictions_for, require_scope + + +class LookupForm(forms.Form): + jurisdiction = forms.ChoiceField() + account_id = forms.IntegerField(required=False, min_value=1) + email = forms.EmailField(required=False) + draft_id = forms.IntegerField(required=False, min_value=1) + status = forms.ChoiceField(required=False, choices=[("", "Any status"), *FilingDraft.Status.choices]) + since = forms.DateField(required=False) + until = forms.DateField(required=False) + + +class RequestForm(forms.Form): + account_wide = forms.BooleanField( + required=False, + label="Delete this account and all of its LITEFile data", + help_text="Includes this local account's filings, plans, browser sessions, and uploaded files.", + ) + drafts = forms.MultipleChoiceField( + required=False, + widget=forms.CheckboxSelectMultiple, + label="Or select individual filings", + help_text="Keep the account and remove only these filings. These choices are ignored when deleting the whole account.", + ) + + +class ReportForm(forms.Form): + jurisdiction = forms.ChoiceField() + start = forms.DateField(widget=forms.DateInput(attrs={"type": "date"})) + end = forms.DateField(widget=forms.DateInput(attrs={"type": "date"})) + grouping = forms.ChoiceField(choices=[("month", "Monthly"), ("day", "Daily")]) + dimension = forms.ChoiceField( + choices=[ + ("all", "Total"), + ("case_type", "Case type"), + ("filer_side", "Side of the case"), + ("filing_for", "Filing for"), + ("zip_code", "Filer ZIP code"), + ("preparation", "Document preparation"), + ("original_pdf_state", "Original PDF form fields"), + ("usage_frequency", "Use frequency"), + ] + ) + + def clean(self): + data = super().clean() + if "start" in data and "end" in data: + if data["start"] > data["end"] or (data["end"] - data["start"]).days > 3660: + raise forms.ValidationError("Choose an ordered range of no more than ten years.") + return data + + +class StaffSite(OTPAdminSite): + login_form = StaffLoginForm + site_header = "LITEFile staff" + site_title = "LITEFile staff" + index_title = "Staff tools" + index_template = "efile/staff/index.html" + site_url = None + + def has_permission(self, request): + if not super().has_permission(request) or not isinstance(request.user.otp_device, TOTPDevice): + return False + if not request.user.otp_device.confirmed: + return False + verified = request.session.get("staff_verified_at", 0) + if timezone.now().timestamp() - verified > settings.LITEFILE_STAFF_SESSION_SECONDS: + return False + if request.session.get("_auth_user_backend") != "django.contrib.auth.backends.ModelBackend": + return False + return bool(request.user.is_superuser or request.user.staff_roles.exists()) + + def each_context(self, request): + context = super().each_context(request) + context.update( + { + "account_access": bool(jurisdictions_for(request.user, "accounts")), + "analytics_access": bool(jurisdictions_for(request.user, "analytics")), + } + ) + return context + + def get_urls(self): + extra = [ + path("accounts/", self.admin_view(self.accounts), name="accounts"), + path("accounts//", self.admin_view(self.account), name="account"), + path("requests/", self.admin_view(self.requests), name="requests"), + path("requests//", self.admin_view(self.privacy_request), name="privacy_request"), + path("analytics/", self.admin_view(self.analytics), name="analytics"), + path("authenticator//", self.admin_view(self.authenticator), name="authenticator"), + ] + return extra + super().get_urls() + + def page(self, request, template, **context): + return render(request, f"efile/staff/{template}.html", {**self.each_context(request), **context}) + + def accounts(self, request): + scopes = jurisdictions_for(request.user, "accounts") + if not scopes: + raise PermissionDenied + form = LookupForm(request.POST if request.method == "POST" else request.session.get("staff_lookup")) + cast(forms.ChoiceField, form.fields["jurisdiction"]).choices = [(scope, scope.capitalize()) for scope in scopes] + accounts = UserProfile.objects.none() + if form.is_valid(): + data = form.cleaned_data + require_scope(request.user, data["jurisdiction"], "accounts") + if request.method == "POST": + request.session["staff_lookup"] = { + key: str(value) if isinstance(value, date) else value for key, value in data.items() + } + audit(request.user, data["jurisdiction"], "account_lookup", "viewed") + accounts = UserProfile.objects.filter( + is_staff=False, is_superuser=False, tyler_jurisdiction=data["jurisdiction"] + ).order_by("pk") + if data["account_id"]: + accounts = accounts.filter(pk=data["account_id"]) + if data["email"]: + accounts = accounts.filter(Q(email__iexact=data["email"]) | Q(tyler_username__iexact=data["email"])) + # Filter by one matching draft rather than independent joins. + if any(data[key] for key in ("draft_id", "status", "since", "until")): + drafts = FilingDraft.objects.filter(jurisdiction=data["jurisdiction"]) + if data["draft_id"]: + drafts = drafts.filter(pk=data["draft_id"]) + if data["status"]: + drafts = drafts.filter(status=data["status"]) + if data["since"]: + drafts = drafts.filter(created_at__date__gte=data["since"]) + if data["until"]: + drafts = drafts.filter(created_at__date__lte=data["until"]) + accounts = accounts.filter(pk__in=drafts.values("user_id")) + return self.page( + request, + "accounts", + title="Find an account", + form=form, + accounts=Paginator(accounts, 25).get_page(request.GET.get("page")), + ) + + def account(self, request, account_id): + target = get_object_or_404(UserProfile, pk=account_id, is_staff=False, is_superuser=False) + require_scope(request.user, target.tyler_jurisdiction, "accounts") + drafts = target.filing_drafts.filter(jurisdiction=target.tyler_jurisdiction).order_by("-created_at") + form = RequestForm( + request.POST if request.method == "POST" and request.POST.get("action") == "request" else None + ) + cast(forms.MultipleChoiceField, form.fields["drafts"]).choices = [ + (str(draft.pk), f"Draft #{draft.pk}: {draft.status}, {draft.created_at:%Y-%m-%d}") for draft in drafts + ] + sessions = account_sessions(target) + session_rows = [ + { + "token": salted_hmac("staff-session", session.pk).hexdigest(), + "expires": session.expire_date, + "estimated_activity": session.expire_date - timedelta(seconds=settings.SESSION_COOKIE_AGE), + } + for session in sessions + ] + if request.method == "POST": + action = request.POST.get("action") + if action == "revoke": + token = request.POST.get("session", "") + ids = [ + session.pk for session in sessions if salted_hmac("staff-session", session.pk).hexdigest() == token + ] + Session.objects.filter(pk__in=ids).delete() + audit( + request.user, + target.tyler_jurisdiction, + "session_revoked", + "completed", + counts={"sessions": len(ids)}, + ) + return redirect("litefile_staff:account", account_id=target.pk) + if action == "request" and form.is_valid(): + try: + item = create_request( + request.user, + target, + account_wide=form.cleaned_data["account_wide"], + draft_ids=[int(value) for value in form.cleaned_data["drafts"]], + ) + except ValueError as exc: + form.add_error(None, str(exc)) + else: + return redirect("litefile_staff:privacy_request", request_id=item.pk) + audit(request.user, target.tyler_jurisdiction, "account_viewed", "viewed") + return self.page( + request, + "account", + title="Account details", + target=target, + drafts=Paginator(drafts, 25).get_page(request.GET.get("page")), + sessions=session_rows, + form=form, + plans=target.filing_plans.count(), + archived=target.archived_cases.count(), + deletion_enabled=settings.LITEFILE_PRIVACY_DELETION_ENABLED, + open_requests=target.privacy_requests.filter(jurisdiction=target.tyler_jurisdiction) + .exclude(status=PrivacyRequest.Status.COMPLETED) + .order_by("-created_at"), + ) + + def requests(self, request): + scopes = jurisdictions_for(request.user, "accounts") + if not scopes: + raise PermissionDenied + items = PrivacyRequest.objects.filter(jurisdiction__in=scopes).order_by("-created_at") + return self.page( + request, "requests", title="Deletion requests", items=Paginator(items, 25).get_page(request.GET.get("page")) + ) + + def privacy_request(self, request, request_id): + item = get_object_or_404(PrivacyRequest, pk=request_id) + require_scope(request.user, item.jurisdiction, "accounts") + if request.method == "POST": + try: + action = request.POST.get("action") + if action == "verify" and request.POST.get("verified") == "yes": + verify_request(item.pk, request.user) + elif action == "process" and request.POST.get("confirmed") == str(item.reference): + try: + expected = signing.loads( + request.POST.get("preview", ""), + salt="staff-deletion-preview", + max_age=settings.LITEFILE_STAFF_SESSION_SECONDS, + ) + except signing.BadSignature as exc: + raise ValueError("Review a fresh deletion preview before confirming.") from exc + if expected["request"] != item.pk or expected["updated_at"] != item.updated_at.isoformat(): + raise ValueError("Review a fresh deletion preview before confirming.") + process_request(item.pk, request.user, expected=expected) + # This request's cached session must not restore a cleared + # search locator when SessionMiddleware saves the response. + request.session.pop("staff_lookup", None) + elif ( + action == "external_resolved" + and item.status == "completed" + and request.POST.get("resolved") == "yes" + ): + item.external_cleanup_pending = False + item.save(update_fields=["external_cleanup_pending", "updated_at"]) + audit( + request.user, + item.jurisdiction, + "external_cleanup", + "operator_confirmed", + reference=item.reference, + ) + else: + raise ValueError("Explicit verification or confirmation is required.") + except ValueError as exc: + messages.error(request, str(exc)) + return redirect("litefile_staff:privacy_request", request_id=item.pk) + plan = preview(item) if item.target_id else {"counts": item.counts, "blockers": []} + token = signing.dumps( + { + "request": item.pk, + "updated_at": item.updated_at.isoformat(), + "counts": plan["counts"], + "draft_ids": plan.get("draft_ids", []), + }, + salt="staff-deletion-preview", + ) + return self.page( + request, + "privacy_request", + title="Deletion request", + item=item, + plan=plan, + deletion_enabled=settings.LITEFILE_PRIVACY_DELETION_ENABLED, + preview_token=token, + ) + + def analytics(self, request): + scopes = jurisdictions_for(request.user, "analytics") + if not scopes: + raise PermissionDenied + last_month = timezone.now().date().replace(day=1) - timedelta(days=1) + form = ReportForm( + request.GET or None, + initial={"start": last_month.replace(day=1), "end": last_month, "grouping": "month", "dimension": "all"}, + ) + cast(forms.ChoiceField, form.fields["jurisdiction"]).choices = [(scope, scope.capitalize()) for scope in scopes] + rows = [] + if form.is_valid(): + data = form.cleaned_data + require_scope(request.user, data["jurisdiction"], "analytics") + try: + rows = report_rows( + [data["jurisdiction"]], + data["start"], + data["end"], + grouping=data["grouping"], + dimension=data["dimension"], + ) + except ValueError as exc: + form.add_error(None, str(exc)) + if request.GET.get("format") == "csv" and not form.errors: + if not settings.LITEFILE_ANALYTICS_EXPORT_ENABLED: + raise PermissionDenied + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="litefile-usage.csv"' + writer = csv.DictWriter( + response, + fieldnames=["period", "jurisdiction", "metric", "filing_kind", "dimension", "value", "count"], + ) + writer.writeheader() + for row in rows: + # Guard spreadsheet formula interpretation even for config labels. + writer.writerow( + { + key: "'" + value if isinstance(value, str) and re.match(r"^[=+@-]", value) else value + for key, value in row.items() + } + ) + audit(request.user, data["jurisdiction"], "analytics_export", "exported", counts={"rows": len(rows)}) + return response + return self.page( + request, + "analytics", + title="Aggregate usage", + form=form, + rows=rows, + health=collection_health(scopes), + collecting=settings.LITEFILE_ANALYTICS_ENABLED, + export_enabled=settings.LITEFILE_ANALYTICS_EXPORT_ENABLED, + ) + + def authenticator(self, request, account_id): + if not request.user.is_superuser: + raise PermissionDenied + target = get_object_or_404(UserProfile, pk=account_id, is_staff=True, is_active=True) + config_url = "" + if request.method == "POST" and request.POST.get("confirmed") == "yes": + from django.db import transaction + + with transaction.atomic(): + UserProfile.objects.select_for_update().get(pk=target.pk) + if TOTPDevice.objects.filter(user=target).exists(): + messages.error( + request, "An authenticator already exists. Use the console recovery procedure to reset it." + ) + else: + device = TOTPDevice.objects.create(user=target, name="Staff authenticator", confirmed=True) + config_url = device.config_url + audit(request.user, "", "totp_provisioned", "completed") + return self.page( + request, "authenticator", title="Provision an authenticator", target=target, config_url=config_url + ) + + +staff_site = StaffSite(name="litefile_staff") + + +class SuperuserOnlyAdmin(admin.ModelAdmin): + def has_module_permission(self, request): + return request.user.is_superuser + + def has_view_permission(self, request, obj=None): + return request.user.is_superuser + + has_add_permission = has_view_permission + has_change_permission = has_view_permission + has_delete_permission = has_view_permission + + def log_addition(self, request, obj, message): + audit(request.user, "", "staff_configuration", "added") + + def log_change(self, request, obj, message): + audit(request.user, "", "staff_configuration", "changed") + + def log_deletions(self, request, queryset): + audit(request.user, "", "staff_configuration", "deleted", counts={"records": queryset.count()}) + + +class StaffUserAdmin(SuperuserOnlyAdmin, UserAdmin): + list_display = ("username", "is_active", "is_superuser") + fieldsets = ( + (None, {"fields": ("username", "password")}), + ("Staff access", {"fields": ("is_active", "is_superuser", "authenticator")}), + ) + readonly_fields = ("authenticator",) + add_fieldsets = ((None, {"classes": ("wide",), "fields": ("username", "password1", "password2")}),) + + def get_queryset(self, request): + return super().get_queryset(request).filter(is_staff=True) + + def save_model(self, request, obj, form, change): + obj.is_staff = True + super().save_model(request, obj, form, change) + + @admin.display(description="TOTP setup") + def authenticator(self, obj): + return format_html( + 'Provision an authenticator', reverse("litefile_staff:authenticator", args=[obj.pk]) + ) + + +class RoleAdmin(SuperuserOnlyAdmin): + list_display = ("user", "jurisdiction", "role") + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "user": + kwargs["queryset"] = UserProfile.objects.filter(is_staff=True, is_active=True) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +staff_site.register(UserProfile, StaffUserAdmin) +staff_site.register(StaffRoleGrant, RoleAdmin) diff --git a/efile_app/efile/staff_logging.py b/efile_app/efile/staff_logging.py new file mode 100644 index 00000000..ff260df9 --- /dev/null +++ b/efile_app/efile/staff_logging.py @@ -0,0 +1,19 @@ +"""Logging is configured before Django's model registry is ready.""" + +import logging + +from django.conf import settings + + +class StaffLogRedactionFilter(logging.Filter): + def filter(self, record): + request = getattr(record, "request", None) + path = getattr(request, "path", "") + prefix = f"/{settings.LITEFILE_STAFF_PATH}/" + if path.startswith(prefix) or prefix in record.getMessage(): + record.msg = "Staff request completed with HTTP status %s" + record.args = (getattr(record, "status_code", "unknown"),) + record.exc_info = None + record.exc_text = None + record.stack_info = None + return True diff --git a/efile_app/efile/staff_models.py b/efile_app/efile/staff_models.py new file mode 100644 index 00000000..0b6f5797 --- /dev/null +++ b/efile_app/efile/staff_models.py @@ -0,0 +1,141 @@ +"""Restricted operational work and permanent, identifier-free usage counters.""" + +import uuid + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models + + +class StaffRoleGrant(models.Model): + class Role(models.TextChoices): + ACCOUNTS = "accounts", "Account management" + ANALYTICS = "analytics", "Aggregate reporting" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="staff_roles") + jurisdiction = models.CharField(max_length=40) + role = models.CharField(max_length=20, choices=Role.choices) + + class Meta: + constraints = [models.UniqueConstraint(fields=["user", "jurisdiction", "role"], name="staff_role_scope")] + + def clean(self): + from efile.utils.config_loader import config_loader + + if self.jurisdiction not in config_loader.get_available_jurisdictions(): + raise ValidationError("Choose a configured jurisdiction.") + if self.user_id and (not self.user.is_staff or not self.user.is_active): + raise ValidationError("Roles require an active staff account.") + + def __str__(self): + return f"Staff #{self.user_id}: {self.jurisdiction} / {self.get_role_display()}" + + +class PrivacyRequest(models.Model): + class Status(models.TextChoices): + RECEIVED = "received", "Received" + VERIFIED = "verified", "Verified" + PROCESSING = "processing", "Processing" + ATTENTION = "attention", "Needs attention" + COMPLETED = "completed", "Live-system deletion completed" + + reference = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + jurisdiction = models.CharField(max_length=40) + target = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="privacy_requests" + ) + account_wide = models.BooleanField(default=False) + draft_ids = models.JSONField(default=list) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.RECEIVED) + operator = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="handled_privacy_requests" + ) + verified_at = models.DateTimeField(null=True) + # Restricted retry manifest. Cleared only after storage and database cleanup. + object_keys = models.JSONField(default=list) + deleted_keys = models.JSONField(default=list) + counts = models.JSONField(default=dict) + outcome = models.CharField(max_length=40, blank=True) + external_cleanup_pending = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(null=True) + + +class StaffAudit(models.Model): + operator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL) + reference = models.UUIDField(null=True) + jurisdiction = models.CharField(max_length=40) + action = models.CharField(max_length=40) + outcome = models.CharField(max_length=40) + counts = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + +class StaffLoginThrottle(models.Model): + key = models.CharField(max_length=64, primary_key=True) + failures = models.PositiveIntegerField(default=0) + window_started = models.DateTimeField() + + +class StoredUpload(models.Model): + """Keep object ownership after a document is removed or replaced.""" + + draft = models.ForeignKey("efile.FilingDraft", on_delete=models.CASCADE, related_name="stored_uploads") + key = models.CharField(max_length=1024, db_index=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=["draft", "key"], name="stored_upload_owner")] + + +class UsageCounter(models.Model): + day = models.DateField() + granularity = models.CharField(max_length=5, default="day") + jurisdiction = models.CharField(max_length=40) + metric = models.CharField(max_length=40) + filing_kind = models.CharField(max_length=10) + dimension = models.CharField(max_length=40, default="all") + value = models.CharField(max_length=80, default="all") + count = models.PositiveBigIntegerField(default=0) + contributors = models.PositiveBigIntegerField(default=0) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["day", "granularity", "jurisdiction", "metric", "filing_kind", "dimension", "value"], + name="usage_counter_bucket", + ) + ] + indexes = [models.Index(fields=["jurisdiction", "day"], name="usage_scope_day")] + + +class UsageEvent(models.Model): + draft = models.ForeignKey("efile.FilingDraft", on_delete=models.CASCADE) + metric = models.CharField(max_length=40) + operation = models.CharField(max_length=80, default="once") + dimensions = models.JSONField(default=dict) + occurred_at = models.DateTimeField() + counted_at = models.DateTimeField(null=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=["draft", "metric", "operation"], name="usage_event_once")] + + +class UsageContributor(models.Model): + """Operational deduplication only; removed with the contributing account.""" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + counter = models.ForeignKey(UsageCounter, on_delete=models.CASCADE) + + class Meta: + constraints = [models.UniqueConstraint(fields=["user", "counter"], name="usage_contributor_once")] + + +class UsageMatter(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + jurisdiction = models.CharField(max_length=40) + identity = models.CharField(max_length=64) + + class Meta: + constraints = [models.UniqueConstraint(fields=["user", "jurisdiction", "identity"], name="usage_matter_once")] diff --git a/efile_app/efile/staff_security.py b/efile_app/efile/staff_security.py new file mode 100644 index 00000000..713ae38c --- /dev/null +++ b/efile_app/efile/staff_security.py @@ -0,0 +1,114 @@ +"""Local-password/TOTP access, independent of court authentication.""" + +from datetime import timedelta + +from django.conf import settings +from django.contrib.auth.backends import ModelBackend +from django.core.exceptions import PermissionDenied, ValidationError +from django.db import transaction +from django.http import JsonResponse +from django.utils import timezone +from django.utils.crypto import salted_hmac +from django_otp.admin import OTPAdminAuthenticationForm +from django_otp.plugins.otp_totp.models import TOTPDevice + +from efile.models import PrivacyRequest, StaffLoginThrottle +from efile.utils.config_loader import config_loader + + +def jurisdictions_for(user, role): + if not user.is_active or not user.is_staff: + return [] + if user.is_superuser: + return config_loader.get_available_jurisdictions() + return list( + user.staff_roles.filter(role=role, jurisdiction__in=config_loader.get_available_jurisdictions()).values_list( + "jurisdiction", flat=True + ) + ) + + +def require_scope(user, jurisdiction, role): + if jurisdiction not in jurisdictions_for(user, role): + raise PermissionDenied + + +class StaffLoginForm(OTPAdminAuthenticationForm): + def clean(self): + # Never call authenticate(): that can send a staff password to Tyler. + username = self.cleaned_data.get("username", "") + password = self.cleaned_data.get("password", "") + key = salted_hmac("staff-login", username.casefold(), algorithm="sha256").hexdigest() + now = timezone.now() + error = None + with transaction.atomic(): + throttle, _ = StaffLoginThrottle.objects.get_or_create(key=key, defaults={"window_started": now}) + throttle = StaffLoginThrottle.objects.select_for_update().get(pk=key) + if throttle.window_started < now - timedelta(minutes=15): + throttle.failures = 0 + throttle.window_started = now + if throttle.failures >= 10: + error = ValidationError("Sign-in temporarily locked. Try again in 15 minutes.") + else: + self.user_cache = ModelBackend().authenticate(self.request, username=username, password=password) + try: + if self.user_cache is None: + raise self.get_invalid_login_error() + self.confirm_login_allowed(self.user_cache) + self.user_cache.backend = "django.contrib.auth.backends.ModelBackend" + self.clean_otp(self.user_cache) + except ValidationError as exc: + error = exc + throttle.failures += 1 + else: + throttle.failures = 0 + self.request.session["staff_verified_at"] = now.timestamp() + self.request.session.set_expiry(settings.LITEFILE_STAFF_SESSION_SECONDS) + throttle.save() + if error: + raise error + return self.cleaned_data + + def confirm_login_allowed(self, user): + super().confirm_login_allowed(user) + if not user.is_superuser and not user.staff_roles.exists(): + raise self.get_invalid_login_error() + + def _chosen_device(self, user): + device = super()._chosen_device(user) + if device is not None and (not isinstance(device, TOTPDevice) or not device.confirmed): + raise ValidationError("Choose a TOTP authenticator.") + if device is None: + # Only one supported factor type, even if other OTP apps are installed. + device = TOTPDevice.objects.filter(user=user, confirmed=True).first() + if device is not None: + device = TOTPDevice.objects.select_for_update().get(pk=device.pk) + if device is None: + raise ValidationError("A staff administrator must provision your TOTP authenticator before sign-in.") + return device + + +class StaffIsolationMiddleware: + """Keep staff identities out of filer workflows and frozen data unavailable.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not request.path.startswith(f"/{settings.LITEFILE_STAFF_PATH}/") and request.user.is_authenticated: + if request.user.is_staff: + return JsonResponse({"error": "Use a separate litigant account for filings."}, status=403) + if ( + request.user.filing_drafts.filter(deletion_pending=True).exists() + or PrivacyRequest.objects.filter(target=request.user, status="processing").exists() + ): + return JsonResponse({"error": "A verified data deletion is in progress."}, status=423) + response = self.get_response(request) + if request.path.startswith(f"/{settings.LITEFILE_STAFF_PATH}/"): + response["Cache-Control"] = "no-store" + response["X-Robots-Tag"] = "noindex, nofollow, noarchive" + # no-referrer makes native form POSTs send Origin: null, which + # Django correctly rejects. Keep same-origin CSRF evidence while + # suppressing referrers to every other origin. + response["Referrer-Policy"] = "same-origin" + return response diff --git a/efile_app/efile/templates/efile/staff/account.html b/efile_app/efile/templates/efile/staff/account.html new file mode 100644 index 00000000..83384b29 --- /dev/null +++ b/efile_app/efile/templates/efile/staff/account.html @@ -0,0 +1,34 @@ +{% extends "efile/staff/base.html" %} +{% block staff_content %} +

Local account #{{ target.pk }}: {{ target.account_email }} — {{ target.tyler_jurisdiction }}

+

Created {{ target.created_at }}. Last sign-in {{ target.last_login|default:"Unknown" }}. Plans: {{ plans }}. Archived cases: {{ archived }}.

+

Filings

+ + + + + + + + + + + + + {% for draft in drafts %}{% empty %}{% endfor %} + +
DraftStatusCreatedUpdatedDocumentsParties
#{{ draft.pk }}{{ draft.status }}{{ draft.created_at }}{{ draft.updated_at }}{{ draft.documents.count }}{{ draft.parties.count }}
No filings.
+ {% if drafts.has_previous %}Previous{% endif %}{% if drafts.has_next %}Next{% endif %} +

Browser sessions

+

Activity is estimated from session expiry and the configured session lifetime.

+ {% for session in sessions %}
{% csrf_token %}

Estimated activity {{ session.estimated_activity }}; expires {{ session.expires }}.

{% empty %}

No stored sessions.

{% endfor %} +

Delete account or filing data

+ {% if open_requests %} +

Continue an existing deletion request

+
    {% for item in open_requests %}
  • Continue deletion review — {{ item.get_status_display }} — {% if item.account_wide %}Whole account{% else %}Selected filings{% endif %}
  • {% endfor %}
+ {% endif %} +

Choose what to delete, then select Review deletion to see the inventory. On the next screen, verify the requester's authority and confirm permanent deletion.

+ {% if not deletion_enabled %}

Deletion is disabled on this deployment. You can review and verify a request, but the final delete action is unavailable until a deployment administrator enables deletion.

{% endif %} +

Use the existing contact channel to verify identity and authority. Select this local account only. Other jurisdictions require separate requests. Do not collect passwords or filing documents for verification.

+
{% csrf_token %}{{ form.as_p }}
+{% endblock %} diff --git a/efile_app/efile/templates/efile/staff/accounts.html b/efile_app/efile/templates/efile/staff/accounts.html new file mode 100644 index 00000000..4276e6ed --- /dev/null +++ b/efile_app/efile/templates/efile/staff/accounts.html @@ -0,0 +1,20 @@ +{% extends "efile/staff/base.html" %} +{% block staff_content %} +
{% csrf_token %}{{ form.as_p }}
+ + + + + + + + + + + + {% for account in accounts %}{% empty %}{% endfor %} + +
Local accounts in the selected jurisdiction
AccountEmailJurisdictionCreated
#{{ account.pk }}{{ account.account_email }}{{ account.tyler_jurisdiction }}{{ account.created_at }}
No matching accounts.
+ {% if accounts.has_previous %}Previous{% endif %} + {% if accounts.has_next %}Next{% endif %} +{% endblock %} diff --git a/efile_app/efile/templates/efile/staff/analytics.html b/efile_app/efile/templates/efile/staff/analytics.html new file mode 100644 index 00000000..798cffd8 --- /dev/null +++ b/efile_app/efile/templates/efile/staff/analytics.html @@ -0,0 +1,23 @@ +{% extends "efile/staff/base.html" %} +{% block staff_content %} +

Reporting timezone: UTC. Collection enabled: {{ collecting|yesno:"Yes,No" }}. First retained coverage: {{ health.first_counter|default:"No collected data" }}. Latest update: {{ health.freshness|default:"None" }}. Pending events: {{ health.pending }}.

+

Successful transmissions measure delivery to the filing service, not court acceptance. Matters and repeat use are counted per local account; they do not identify unique people. Case types use configured categories; unmatched types are grouped as other or unknown.

+

Small cohorts suppress the whole requested breakdown and period, including its subtotals and rare labels. Detailed breakdowns require complete UTC calendar months.

+
{{ form.as_p }}{% if export_enabled %}{% endif %}
+ + + + + + + + + + + + + + {% for row in rows %}{% empty %}{% endfor %} + +
PeriodJurisdictionMetricNew/existingBreakdownValueCount
{{ row.period }}{{ row.jurisdiction }}{{ row.metric }}{{ row.filing_kind }}{{ row.dimension }}{{ row.value }}{{ row.count }}
No rows in this range.
+{% endblock %} diff --git a/efile_app/efile/templates/efile/staff/authenticator.html b/efile_app/efile/templates/efile/staff/authenticator.html new file mode 100644 index 00000000..e2464bde --- /dev/null +++ b/efile_app/efile/templates/efile/staff/authenticator.html @@ -0,0 +1,5 @@ +{% extends "efile/staff/base.html" %} +{% block staff_content %} +

Provision TOTP for local staff account #{{ target.pk }} ({{ target.username }}). Verify the recipient through an approved staff onboarding channel.

+ {% if config_url %}

This secret is shown once. Transfer it securely to the administrator and have them add it to their authenticator. Do not put it in email, tickets, screenshots, or application logs.

{{ config_url }}

{% else %}
{% csrf_token %}
{% endif %} +{% endblock %} diff --git a/efile_app/efile/templates/efile/staff/base.html b/efile_app/efile/templates/efile/staff/base.html new file mode 100644 index 00000000..6f1a4f9e --- /dev/null +++ b/efile_app/efile/templates/efile/staff/base.html @@ -0,0 +1,5 @@ +{% extends "admin/base_site.html" %} +{% block breadcrumbs %}{% endblock %} +{% block content %} + {% block staff_content %}{% endblock %} +{% endblock %} diff --git a/efile_app/efile/templates/efile/staff/index.html b/efile_app/efile/templates/efile/staff/index.html new file mode 100644 index 00000000..128ef7f5 --- /dev/null +++ b/efile_app/efile/templates/efile/staff/index.html @@ -0,0 +1,11 @@ +{% extends "admin/index.html" %} +{% block content %} +
+

Staff tools

+ +
+ {{ block.super }} +{% endblock %} diff --git a/efile_app/efile/templates/efile/staff/privacy_request.html b/efile_app/efile/templates/efile/staff/privacy_request.html new file mode 100644 index 00000000..e324a243 --- /dev/null +++ b/efile_app/efile/templates/efile/staff/privacy_request.html @@ -0,0 +1,27 @@ +{% extends "efile/staff/base.html" %} +{% block staff_content %} +

Reference {{ item.reference }} — {{ item.jurisdiction }} — {{ item.get_status_display }}.

+

Outcome: {{ item.outcome|default:"Pending" }}. {% if item.account_wide %}Scope: delete this local account and all of its LITEFile data.{% else %}Scope: delete selected filings and keep the account.{% endif %}

+ {% if item.target_id %}

Account #{{ item.target_id }} — {{ item.target.account_email }}.

{% endif %} +

Deletion preview

+
{% for name, count in plan.counts.items %}
{{ name }}
{{ count }}
{% endfor %}
+ {% if plan.blockers %} +

Requires operator review

+
    {% for blocker in plan.blockers %}
  • {{ blocker }}
  • {% endfor %}
+ {% endif %} +

Shared objects and correction chains outside this scope are blockers. Submitting filings and uncertain transmission outcomes require reconciliation; active extraction workers must stop before deletion.

+

Deleting LITEFile's copies does not withdraw a filing, erase court records, or delete a Tyler account. Backup and provider cleanup need a separate operator disposition.

+ {% if item.status != 'completed' and not deletion_enabled %}

Deletion is disabled on this deployment. You can review and verify this request. Ask a deployment administrator to enable deletion before the final confirmation.

{% endif %} + {% if item.status == 'received' %} +

Verify the requester's authority

+

Review the inventory above and verify the requester's identity and authority. Then continue to the final deletion confirmation.

+
{% csrf_token %}
+ {% elif item.status != 'completed' %} +

Confirm permanent deletion

+ {% if deletion_enabled %} + {% if plan.blockers %}

Resolve the items under Requires operator review before deleting this data.

{% endif %} +
{% csrf_token %}
+ {% endif %} + {% endif %} + {% if item.status == 'completed' and item.external_cleanup_pending %}

Live-system deletion completed. Backup/provider disposition remains outstanding; do not describe the request as fully erased.

{% csrf_token %}
{% endif %} +{% endblock %} diff --git a/efile_app/efile/templates/efile/staff/requests.html b/efile_app/efile/templates/efile/staff/requests.html new file mode 100644 index 00000000..13e85cb3 --- /dev/null +++ b/efile_app/efile/templates/efile/staff/requests.html @@ -0,0 +1,18 @@ +{% extends "efile/staff/base.html" %} +{% block staff_content %} + + + + + + + + + + + + {% for item in items %}{% empty %}{% endfor %} + +
ReferenceJurisdictionStatusCreatedOutcome
{{ item.reference }}{{ item.jurisdiction }}{{ item.get_status_display }}{{ item.created_at }}{{ item.outcome }}
No requests.
+ {% if items.has_previous %}Previous{% endif %}{% if items.has_next %}Next{% endif %} +{% endblock %} diff --git a/efile_app/efile/templates/efile/terms_of_service.html b/efile_app/efile/templates/efile/terms_of_service.html index e1fb9262..4f97fdc0 100644 --- a/efile_app/efile/templates/efile/terms_of_service.html +++ b/efile_app/efile/templates/efile/terms_of_service.html @@ -48,6 +48,9 @@

{% translate "Using LITEFile" %}

{% translate "Information we collect" %}

+

+ {% translate "We keep aggregate usage counts to understand which kinds of filings people make, which jurisdictions they use, whether they start or respond to cases, whether they file for themselves or someone else, filer ZIP codes, and whether uploaded PDFs contain form fields. Reports do not include names, email addresses, account identifiers, case numbers, documents, or authentication credentials. We restrict access to these reports and hide small groups to reduce the risk of identifying a person. Aggregate counts may be retained indefinitely after account or filing data is deleted." %} +

{% translate "To provide e-filing, we may collect information you enter or upload, including your name, contact information, addresses, party information, case details, filing documents, payment information, and account credentials. We may also collect browser, device, IP address, and activity information needed for security, troubleshooting, and service improvement." %}

@@ -129,6 +132,9 @@

{% translate "Changes and general terms" %}

{% translate "Questions or privacy requests" %}

+

+ {% translate "You can ask us to delete a particular filing's data or the data associated with your LITEFile account through the contact channel below. We verify your identity and authority before processing a request. Accounts in different jurisdictions are handled separately. Deleting LITEFile's copies does not withdraw a filing, erase a court record, or delete your account with the court's filing service. We may need to resolve a filing still in progress, shared records, retention requirements, or backup and provider-held copies before confirming what has been deleted." %} +

{% translate "Contact the Suffolk LIT Lab at" %} {{ config.jurisdiction.contact_email|default:"litlab@suffolk.edu" }} diff --git a/efile_app/efile/tests/test_extraction_claims.py b/efile_app/efile/tests/test_extraction_claims.py index 0fe2628b..d639c481 100644 --- a/efile_app/efile/tests/test_extraction_claims.py +++ b/efile_app/efile/tests/test_extraction_claims.py @@ -219,7 +219,8 @@ def test_supervisor_terminates_a_timed_out_child_and_requeues(lead): "efile.management.commands.process_document_extractions.multiprocessing.get_context", return_value=Mock(Process=Mock(return_value=child)), ), - patch("efile.management.commands.process_document_extractions.time.monotonic", side_effect=[0, 2, 2]), + # Initial rollup, child deadline, join deadline, rollup check, timeout. + patch("efile.management.commands.process_document_extractions.time.monotonic", side_effect=[0, 0, 2, 2, 2]), ): call_command("process_document_extractions", once=True) child.terminate.assert_called_once() diff --git a/efile_app/efile/tests/test_staff_bootstrap.py b/efile_app/efile/tests/test_staff_bootstrap.py new file mode 100644 index 00000000..ee9b6c1f --- /dev/null +++ b/efile_app/efile/tests/test_staff_bootstrap.py @@ -0,0 +1,125 @@ +"""Initial staff credentials come from explicit secrets, never defaults.""" + +import base64 +import io +import secrets + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from django_otp.plugins.otp_totp.models import TOTPDevice + +from efile.models import FilingDocument, FilingDraft, UsageCounter, UserProfile + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def bootstrap_environment(monkeypatch): + for suffix in ("USERNAME", "PASSWORD", "EMAIL", "TOTP_SECRET"): + monkeypatch.delenv(f"LITEFILE_STAFF_BOOTSTRAP_{suffix}", raising=False) + + +def configure(monkeypatch): + password = secrets.token_urlsafe(24) + monkeypatch.setenv("LITEFILE_STAFF_BOOTSTRAP_USERNAME", "environment-admin") + monkeypatch.setenv("LITEFILE_STAFF_BOOTSTRAP_PASSWORD", password) + return password + + +def test_bootstrap_has_no_default_account(): + with pytest.raises(CommandError, match="no default account"): + call_command("bootstrap_staff") + assert not UserProfile.objects.exists() + + +@pytest.mark.parametrize("password", ["", "password"]) +def test_bootstrap_rejects_missing_or_weak_password(monkeypatch, password): + configure(monkeypatch) + monkeypatch.setenv("LITEFILE_STAFF_BOOTSTRAP_PASSWORD", password) + with pytest.raises(CommandError): + call_command("bootstrap_staff") + assert not UserProfile.objects.exists() + assert not TOTPDevice.objects.exists() + + +def test_generated_totp_and_password_are_created_together(monkeypatch): + password = configure(monkeypatch) + output = io.StringIO() + call_command("bootstrap_staff", stdout=output) + user = UserProfile.objects.get(username="environment-admin") + device = TOTPDevice.objects.get(user=user) + assert user.is_active and user.is_staff and user.is_superuser + assert user.check_password(password) + assert device.confirmed and len(bytes.fromhex(device.key)) == 20 + assert device.config_url in output.getvalue() + assert password not in output.getvalue() + + +def test_supplied_secret_and_repeated_bootstrap_never_echo_or_overwrite(monkeypatch): + password = configure(monkeypatch) + key = b"t" * 20 + secret = base64.b32encode(key).decode() + monkeypatch.setenv("LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET", secret) + output = io.StringIO() + call_command("bootstrap_staff", stdout=output) + device = TOTPDevice.objects.get() + assert device.key == key.hex() + assert secret not in output.getvalue() + assert "otpauth://" not in output.getvalue() + monkeypatch.setenv("LITEFILE_STAFF_BOOTSTRAP_PASSWORD", secrets.token_urlsafe(24)) + monkeypatch.setenv("LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET", base64.b32encode(b"x" * 20).decode()) + call_command("bootstrap_staff", stdout=output) + device.refresh_from_db() + assert device.key == key.hex() + assert TOTPDevice.objects.count() == 1 + assert UserProfile.objects.get().check_password(password) + + +@pytest.mark.parametrize("secret", ["not-a-base32-secret!", "JBSWY3DPEHPK3PXP"]) +def test_invalid_or_short_totp_rolls_back_all_creation(monkeypatch, secret): + configure(monkeypatch) + monkeypatch.setenv("LITEFILE_STAFF_BOOTSTRAP_TOTP_SECRET", secret) + with pytest.raises(CommandError): + call_command("bootstrap_staff") + assert not UserProfile.objects.exists() + + +def test_bootstrap_never_promotes_existing_filer(monkeypatch): + configure(monkeypatch) + original_password = secrets.token_urlsafe(24) + user = UserProfile.objects.create_user(username="environment-admin", password=original_password) + with pytest.raises(CommandError, match="cannot promote"): + call_command("bootstrap_staff") + user.refresh_from_db() + assert not user.is_staff and not user.is_superuser + assert user.check_password(original_password) + + +def test_demo_is_local_idempotent_and_has_no_uploads(settings, monkeypatch): + settings.DEBUG = True + settings.LITEFILE_ANALYTICS_ENABLED = False + monkeypatch.delenv("FLY_APP_NAME", raising=False) + output = io.StringIO() + call_command("seed_staff_demo", stdout=output) + before = list(UsageCounter.objects.values_list("pk", "count", "contributors").order_by("pk")) + assert before and all(contributors == 5 for _, _, contributors in before) + draft_count = FilingDraft.objects.count() + assert draft_count > 0 + assert not FilingDocument.objects.exists() + assert all(user.analytics_excluded and not user.has_usable_password() for user in UserProfile.objects.all()) + call_command("seed_staff_demo", stdout=output) + assert list(UsageCounter.objects.values_list("pk", "count", "contributors").order_by("pk")) == before + assert FilingDraft.objects.count() == draft_count + + +@pytest.mark.parametrize("deployed", [False, True]) +def test_demo_refuses_production_or_deployed_host(settings, monkeypatch, deployed): + settings.DEBUG = deployed + if deployed: + monkeypatch.setenv("FLY_APP_NAME", "deployed-app") + else: + monkeypatch.delenv("FLY_APP_NAME", raising=False) + with pytest.raises(CommandError): + call_command("seed_staff_demo") + assert not UserProfile.objects.exists() diff --git a/efile_app/efile/tests/test_staff_tools.py b/efile_app/efile/tests/test_staff_tools.py new file mode 100644 index 00000000..5e2663e2 --- /dev/null +++ b/efile_app/efile/tests/test_staff_tools.py @@ -0,0 +1,614 @@ +"""Exercise real permissions, TOTP login, privacy cleanup, and usage reports.""" + +import csv +import io +import json +import uuid +from datetime import UTC, date, datetime, timedelta +from unittest.mock import Mock, patch + +import pytest +from django.contrib.sessions.backends.db import SessionStore +from django.contrib.sessions.models import Session +from django.core.exceptions import PermissionDenied +from django.urls import reverse +from django.utils import timezone +from django_otp.oath import totp +from django_otp.plugins.otp_totp.models import TOTPDevice + +from efile.models import ( + ArchivedCase, + DocumentExtraction, + FilingDocument, + FilingDraft, + FilingMetadataEvent, + FilingParty, + FilingPlan, + InterviewHandoff, + PendingActivation, + PrivacyRequest, + StaffAudit, + StaffRoleGrant, + StoredUpload, + UsageContributor, + UsageCounter, + UsageEvent, + UsageMatter, +) +from efile.services.analytics import count_event, drain_events, record_event, record_matter, report_rows +from efile.services.document_extractions import claim_next_extraction, process_document_extraction +from efile.services.document_uploads import upload_files +from efile.services.privacy import create_request, preview, process_request, verify_request +from efile.utils.s3_upload_handler import S3UploadHandler + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def owner(django_user_model): + return django_user_model.objects.create_user( + username="litigant", + email="private@example.com", + tyler_username="private@example.com", + tyler_jurisdiction="illinois", + ) + + +@pytest.fixture +def administrator(django_user_model): + user = django_user_model.objects.create_user(username="staff", password="a-strong-local-password", is_staff=True) + StaffRoleGrant.objects.create(user=user, jurisdiction="illinois", role="accounts") + return user + + +def verified_client(client, user): + device = TOTPDevice.objects.create(user=user, name="Test authenticator") + client.force_login(user, backend="django.contrib.auth.backends.ModelBackend") + session = client.session + session["otp_device_id"] = device.persistent_id + session["staff_verified_at"] = timezone.now().timestamp() + session.save() + return client + + +def make_session(user, draft=None): + session = SessionStore() + session["_auth_user_id"] = str(user.pk) + session["auth_tokens"] = {"private": "SECRET-COURT-TOKEN"} + if draft: + session["filing_draft_id"] = draft.pk + session.save() + return session.session_key + + +@pytest.mark.parametrize("endpoint", ["accounts", "requests", "analytics"]) +def test_staff_pages_require_totp_and_role(client, owner, administrator, endpoint): + url = reverse(f"litefile_staff:{endpoint}") + assert client.get(url).status_code == 302 + client.force_login(owner) + assert client.get(url).status_code == 302 + client.force_login(administrator, backend="django.contrib.auth.backends.ModelBackend") + assert client.get(url).status_code == 302 + verified_client(client, administrator) + assert client.get(url).status_code == (403 if endpoint == "analytics" else 200) + + +def test_analytics_only_role_cannot_read_delete_or_grant(client, django_user_model, owner, settings): + user = django_user_model.objects.create_user(username="reporter", is_staff=True) + StaffRoleGrant.objects.create(user=user, jurisdiction="illinois", role="analytics") + verified_client(client, user) + assert client.get(reverse("litefile_staff:analytics")).status_code == 200 + for url in [ + reverse("litefile_staff:account", args=[owner.pk]), + reverse("litefile_staff:requests"), + reverse("litefile_staff:efile_staffrolegrant_add"), + ]: + assert client.get(url).status_code == 403 + assert client.post(url, {"action": "process"}).status_code == 403 + settings.LITEFILE_ANALYTICS_EXPORT_ENABLED = True + response = client.get( + reverse("litefile_staff:analytics"), + { + "jurisdiction": "vermont", + "start": "2026-09-01", + "end": "2026-09-30", + "grouping": "month", + "dimension": "all", + "format": "csv", + }, + ) + assert response["Content-Type"].startswith("text/html") # Invalid scoped choice never exports. + + +def test_totp_login_local_only_replay_and_expiry(client, administrator, settings): + device = TOTPDevice.objects.create(user=administrator, name="Authenticator") + url = reverse("litefile_staff:login") + with patch("efile.authentication.auth_with_tyler_api") as tyler: + response = client.post(url, {"username": administrator.username, "password": "a-strong-local-password"}) + assert response.status_code == 200 + assert "_auth_user_id" not in client.session + token = str(totp(device.bin_key, step=device.step, t0=device.t0, digits=device.digits)) + response = client.post( + url, {"username": administrator.username, "password": "a-strong-local-password", "otp_token": token} + ) + assert response.status_code == 302 + assert client.get(reverse("litefile_staff:accounts")).status_code == 200 + tyler.assert_not_called() + client.logout() + assert ( + client.post( + url, {"username": administrator.username, "password": "a-strong-local-password", "otp_token": token} + ).status_code + == 200 + ) + verified_client(client, administrator) + session = client.session + session["staff_verified_at"] = timezone.now().timestamp() - settings.LITEFILE_STAFF_SESSION_SECONDS - 1 + session.save() + assert client.get(reverse("litefile_staff:accounts")).status_code == 302 + + +def test_role_revocation_and_private_headers(client, administrator): + verified_client(client, administrator) + response = client.get(reverse("litefile_staff:accounts")) + assert response["Cache-Control"] == "no-store" + assert response["Referrer-Policy"] == "same-origin" + assert "noindex" in response["X-Robots-Tag"] + administrator.staff_roles.all().delete() + assert client.get(reverse("litefile_staff:accounts")).status_code == 302 + client.logout() + assert client.get("/admin/").status_code == 404 + + +@pytest.mark.parametrize("secure", [False, True]) +def test_staff_login_preserves_same_origin_csrf_checks(administrator, secure): + from django.test import Client + + device = TOTPDevice.objects.create(user=administrator, name="CSRF test authenticator") + browser = Client(enforce_csrf_checks=True) + url = reverse("litefile_staff:login") + host = "localhost:8001" + origin = f"{'https' if secure else 'http'}://{host}" + response = browser.get(url, secure=secure, HTTP_HOST=host) + assert response["Referrer-Policy"] == "same-origin" + data = { + "username": administrator.username, + "password": "a-strong-local-password", + "otp_token": str(totp(device.bin_key)).zfill(6), + "csrfmiddlewaretoken": browser.cookies["csrftoken"].value, + } + for untrusted_origin in ("null", "https://untrusted.invalid"): + assert browser.post(url, data, secure=secure, HTTP_HOST=host, HTTP_ORIGIN=untrusted_origin).status_code == 403 + assert ( + browser.post( + url, + {key: value for key, value in data.items() if key != "csrfmiddlewaretoken"}, + secure=secure, + HTTP_HOST=host, + HTTP_ORIGIN=origin, + ).status_code + == 403 + ) + with patch("efile.authentication.auth_with_tyler_api") as court_auth: + assert browser.post(url, data, secure=secure, HTTP_HOST=host, HTTP_ORIGIN=origin).status_code == 302 + assert browser.get(reverse("litefile_staff:accounts"), secure=secure, HTTP_HOST=host).status_code == 200 + court_auth.assert_not_called() + + +def test_lookup_scoped_minimal_and_get_does_not_delete(client, owner, administrator, django_user_model): + other = django_user_model.objects.create_user( + username="other-state", email=owner.email, tyler_jurisdiction="vermont" + ) + draft = FilingDraft.objects.create( + user=owner, jurisdiction="illinois", submission_response={"payment": "PRIVATE-PAYMENT"} + ) + key = make_session(owner, draft) + verified_client(client, administrator) + response = client.post(reverse("litefile_staff:accounts"), {"jurisdiction": "illinois", "email": owner.email}) + assert response.status_code == 200 + assert list(response.context["accounts"]) == [owner] + assert client.get(reverse("litefile_staff:account", args=[other.pk])).status_code == 403 + response = client.get(reverse("litefile_staff:account", args=[owner.pk]), {"action": "revoke"}) + assert response.status_code == 200 + assert b"SECRET-COURT-TOKEN" not in response.content + assert b"PRIVATE-PAYMENT" not in response.content + assert Session.objects.filter(pk=key).exists() + token = response.context["sessions"][0]["token"] + client.post(reverse("litefile_staff:account", args=[owner.pk]), {"action": "revoke", "session": token}) + assert not Session.objects.filter(pk=key).exists() + + +def test_full_deletion_inventory_and_retry(owner, administrator, django_user_model, settings): + settings.LITEFILE_PRIVACY_DELETION_ENABLED = True + other = django_user_model.objects.create_user(username="unrelated", tyler_jurisdiction="illinois") + plan = FilingPlan.objects.create(user=owner, jurisdiction="illinois", title="Private matter") + draft = FilingDraft.objects.create(user=owner, jurisdiction="illinois", plan=plan) + correction = FilingDraft.objects.create(user=owner, jurisdiction="illinois", correction_of=draft) + document = FilingDocument.objects.create(draft=draft, role="lead", s3_key="prepared", original_s3_key="original") + FilingDocument.objects.create(draft=correction, role="lead", s3_key="prepared", original_s3_key="original") + FilingParty.objects.create(draft=draft, role="filer", first_name="Private name", email=owner.email) + DocumentExtraction.objects.create(document=document, evidence={"private": "data"}) + InterviewHandoff.objects.create( + draft=draft, source="test", source_id="1", idempotency_key="1", fingerprint="hash", payload={"private": "data"} + ) + FilingMetadataEvent.objects.create(draft=draft, path="case_title", kind="user_edit", value={"private": "data"}) + ArchivedCase.objects.create(user=owner, jurisdiction="illinois", case_tracking_id="private-case") + PendingActivation.remember(owner.email, "illinois") + PendingActivation.remember(owner.email, "vermont") + owned_session = make_session(owner, draft) + unrelated_session = make_session(other) + request = create_request(administrator, owner, account_wide=True) + handler = Mock() + with pytest.raises(ValueError, match="Verify"): + process_request(request.pk, administrator, handler=handler) + verify_request(request.pk, administrator) + assert preview(request)["counts"]["objects"] == 2 + handler.erase_file.side_effect = [{"success": True}, {"success": False}] + request = process_request(request.pk, administrator, handler=handler) + assert request.status == "attention" and request.outcome == "storage_failed" + assert len(request.deleted_keys) == 1 + assert FilingDraft.objects.filter(pk=draft.pk, deletion_pending=True).exists() + assert not Session.objects.filter(pk=owned_session).exists() + with pytest.raises(PermissionDenied): + document.name = "Restore erased data" + document.save() + with pytest.raises(PermissionDenied): + FilingDraft.objects.create(user=owner, jurisdiction="illinois") + handler.erase_file.side_effect = None + handler.erase_file.return_value = {"success": True} + request = process_request(request.pk, administrator, handler=handler) + assert request.status == "completed" and request.external_cleanup_pending + assert not django_user_model.objects.filter(pk=owner.pk).exists() + with pytest.raises(PermissionDenied): + make_session(owner) # A late login cannot recreate deleted credentials. + assert Session.objects.filter(pk=unrelated_session).exists() + assert PendingActivation.exists_for(owner.email, "vermont") + assert not PendingActivation.exists_for(owner.email, "illinois") + assert not any([request.target_id, request.object_keys, request.deleted_keys, request.draft_ids]) + assert handler.erase_file.call_count == 3 + assert process_request(request.pk, administrator, handler=handler).status == "completed" + assert "private" not in json.dumps(list(StaffAudit.objects.values("counts", "outcome"))) + + +def test_selected_scope_preserves_unrelated_data_and_blocks_shared_objects(owner, administrator, settings): + settings.LITEFILE_PRIVACY_DELETION_ENABLED = True + draft = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + preserved = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + removed = FilingDocument.objects.create(draft=draft, role="lead", s3_key="old-upload") + removed.delete() # The upload inventory must outlive the document row. + assert StoredUpload.objects.filter(draft=draft, key="old-upload").exists() + first_session = make_session(owner, draft) + preserved_session = make_session(owner, preserved) + request = create_request(administrator, owner, account_wide=False, draft_ids=[draft.pk]) + verify_request(request.pk, administrator) + handler = Mock(erase_file=Mock(return_value={"success": True})) + process_request(request.pk, administrator, handler=handler) + handler.erase_file.assert_called_once_with("old-upload") + assert owner.filing_drafts.count() == 1 + assert Session.objects.filter(pk=preserved_session).exists() + assert not Session.objects.filter(pk=first_session).exists() + shared = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + FilingDocument.objects.create(draft=shared, role="lead", s3_key="shared") + FilingDocument.objects.create(draft=preserved, role="lead", s3_key="shared") + request = create_request(administrator, owner, account_wide=False, draft_ids=[shared.pk]) + verify_request(request.pk, administrator) + assert "shared_objects" in preview(request)["blockers"] + assert process_request(request.pk, administrator, handler=handler).status == "attention" + assert handler.erase_file.call_count == 1 + + +@pytest.mark.parametrize("status", ["submitting", "error"]) +def test_submission_blocks_deletion(owner, administrator, settings, status): + settings.LITEFILE_PRIVACY_DELETION_ENABLED = True + FilingDraft.objects.create(user=owner, jurisdiction="illinois", status=status) + request = create_request(administrator, owner, account_wide=True) + verify_request(request.pk, administrator) + handler = Mock() + assert process_request(request.pk, administrator, handler=handler).outcome == "submission_requires_reconciliation" + handler.erase_file.assert_not_called() + + +def test_s3_permanent_erasure_exact_versions(monkeypatch): + handler = S3UploadHandler() + handler.s3_client = Mock() + handler.bucket_name = "private-test-bucket" + monkeypatch.setattr(handler, "_ensure_initialized", Mock(return_value=True)) + handler.s3_client.get_bucket_versioning.return_value = {"Status": "Enabled"} + paginator = handler.s3_client.get_paginator.return_value + paginator.paginate.side_effect = [ + [ + { + "Versions": [{"Key": "key", "VersionId": "v1"}, {"Key": "key-other", "VersionId": "v2"}], + "DeleteMarkers": [{"Key": "key", "VersionId": "marker"}], + } + ], + [], + ] + handler.s3_client.delete_objects.return_value = {} + assert handler.erase_file("key") == {"success": True} + assert handler.s3_client.delete_objects.call_args.kwargs["Delete"]["Objects"] == [ + {"Key": "key", "VersionId": "v1"}, + {"Key": "key", "VersionId": "marker"}, + ] + handler.s3_client.delete_object.assert_not_called() + paginator.paginate.side_effect = [[{"Versions": [{"Key": "key", "VersionId": "locked"}]}]] + handler.s3_client.delete_objects.return_value = {"Errors": [{"Code": "AccessDenied"}]} + assert handler.erase_file("key") == {"success": False} + + +def test_analytics_idempotency_dates_deletion_and_suppression(owner, administrator, settings): + settings.LITEFILE_ANALYTICS_ENABLED = True + settings.LITEFILE_PRIVACY_DELETION_ENABLED = True + at = datetime(2026, 9, 30, 23, 59, tzinfo=UTC) + with patch("efile.services.analytics.timezone.now", return_value=at): + draft = FilingDraft.objects.create( + user=owner, jurisdiction="illinois", existing_case="new", case_type_name="Name Change" + ) + FilingParty.objects.create( + draft=draft, role="filer", party_type="plaintiff", party_side="initiating", zip_code="02108-9999" + ) + record_event(draft, "review") + record_event(draft, "review") + record_event(draft, "submission_attempt", operation="logical-operation") + record_event(draft, "submission_attempt", operation="logical-operation") + with patch("efile.services.analytics.timezone.now", return_value=at + timedelta(minutes=1)): + draft.mark_submitted({}) + drain_events() + before = list(UsageCounter.objects.order_by("pk").values("day", "metric", "count", "dimension", "value")) + drain_events() + assert before == list(UsageCounter.objects.order_by("pk").values("day", "metric", "count", "dimension", "value")) + assert UsageCounter.objects.get(metric="review", granularity="day", dimension="all").day == date(2026, 9, 30) + assert UsageCounter.objects.get(metric="transmitted", granularity="day", dimension="all").day == date(2026, 10, 1) + assert set(UsageEvent.objects.get(metric="review").dimensions) <= { + "case_type", + "filer_side", + "filing_for", + "zip_code", + "usage_frequency", + "filing_kind", + } + rows = report_rows(["illinois"], date(2026, 9, 1), date(2026, 9, 30), dimension="zip_code") + assert rows and all(row["count"] == "suppressed" and row["value"] == "suppressed" for row in rows) + request = create_request(administrator, owner, account_wide=True) + verify_request(request.pk, administrator) + process_request(request.pk, administrator, handler=Mock()) + assert ( + not UsageEvent.objects.exists() and not UsageContributor.objects.exists() and not UsageMatter.objects.exists() + ) + assert before == list(UsageCounter.objects.order_by("pk").values("day", "metric", "count", "dimension", "value")) + + +def test_analytics_worker_failure_retry_allowlist_and_staff_exclusion(owner, administrator, settings): + settings.LITEFILE_ANALYTICS_ENABLED = True + draft = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + event = UsageEvent.objects.get(draft=draft) + with patch("efile.services.analytics.UsageCounter.objects.get_or_create", side_effect=RuntimeError("unavailable")): + with pytest.raises(RuntimeError): + count_event(event.pk) + assert not UsageCounter.objects.exists() + drain_events() + assert UsageCounter.objects.get(dimension="all", granularity="day").count == 1 + event.dimensions["email"] = owner.email + event.counted_at = None + event.save() + with pytest.raises(ValueError, match="Disallowed"): + count_event(event.pk) + staff_draft = FilingDraft.objects.create(user=administrator, jurisdiction="illinois") + assert not UsageEvent.objects.filter(draft=staff_draft).exists() + settings.DEBUG = True + other = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + assert not UsageEvent.objects.filter(draft=other).exists() + + +def test_extraction_supervisor_rolls_usage_and_survives_rollup_failure(owner, settings): + from django.core.management import call_command + + settings.LITEFILE_ANALYTICS_ENABLED = True + draft = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + event = UsageEvent.objects.get(draft=draft) + with patch("efile.services.document_extractions.claim_next_extraction", return_value=None): + with patch("efile.management.commands.process_document_extractions.call_command", side_effect=RuntimeError): + call_command("process_document_extractions", once=True) + event.refresh_from_db() + assert event.counted_at is None + assert not UsageCounter.objects.exists() + call_command("process_document_extractions", once=True) + event.refresh_from_db() + assert event.counted_at is not None + assert event.dimensions == {} + assert UsageCounter.objects.get(dimension="all", granularity="day").count == 1 + + +def test_dashboard_csv_match(client, administrator, settings): + StaffRoleGrant.objects.create(user=administrator, jurisdiction="illinois", role="analytics") + settings.LITEFILE_ANALYTICS_EXPORT_ENABLED = True + UsageCounter.objects.create( + day=date(2026, 9, 1), + granularity="month", + jurisdiction="illinois", + metric="started", + filing_kind="new", + count=17, + contributors=10, + ) + verified_client(client, administrator) + filters = { + "jurisdiction": "illinois", + "start": "2026-09-01", + "end": "2026-09-30", + "grouping": "month", + "dimension": "all", + } + url = reverse("litefile_staff:analytics") + response = client.get(url, filters) + assert response.status_code == 200 + csv_response = client.get(url, {**filters, "format": "csv"}) + rows = list(csv.DictReader(io.StringIO(csv_response.content.decode()))) + assert rows == [{key: str(value) for key, value in row.items()} for row in response.context["rows"]] + UsageCounter.objects.create( + day=date(2026, 9, 1), + granularity="month", + jurisdiction="illinois", + metric="started", + filing_kind="new", + dimension="zip_code", + value="02108", + count=1, + contributors=1, + ) + assert client.get(url, filters).context["rows"][0]["count"] == 17 + details = {**filters, "dimension": "zip_code"} + assert client.get(url, details).context["rows"][0]["count"] == "suppressed" + assert "02108" not in client.get(url, {**details, "format": "csv"}).content.decode() + + +def test_staff_privacy_workflow_csrf(client, owner, administrator, settings): + settings.LITEFILE_PRIVACY_DELETION_ENABLED = True + verified_client(client, administrator) + client.post(reverse("litefile_staff:accounts"), {"jurisdiction": "illinois", "email": owner.email}) + assert client.session["staff_lookup"]["email"] == owner.email + assert "Review deletion" in client.get(reverse("litefile_staff:account", args=[owner.pk])).content.decode() + response = client.post( + reverse("litefile_staff:account", args=[owner.pk]), {"action": "request", "account_wide": "on"} + ) + assert response.status_code == 302 + request = PrivacyRequest.objects.get(target=owner) + assert type(owner).objects.filter(pk=owner.pk).exists() + url = reverse("litefile_staff:privacy_request", args=[request.pk]) + assert client.get(url, {"action": "process", "confirmed": str(request.reference)}).status_code == 200 + request.refresh_from_db() + assert request.status == "received" + assert "Verify and continue" in client.get(url).content.decode() + client.post(url, {"action": "process", "confirmed": str(request.reference)}) + request.refresh_from_db() + assert request.status == "received" + client.post(url, {"action": "verify", "verified": "yes"}) + settings.LITEFILE_PRIVACY_DELETION_ENABLED = False + disabled_page = client.get(url).content.decode() + assert "Deletion is disabled on this deployment" in disabled_page + assert "Permanently delete account" not in disabled_page + settings.LITEFILE_PRIVACY_DELETION_ENABLED = True + assert "Permanently delete account" in client.get(url).content.decode() + token = client.get(url).context["preview_token"] + client.post(url, {"action": "process", "confirmed": str(request.reference), "preview": token}) + request.refresh_from_db() + assert request.status == "completed" + assert "staff_lookup" not in client.session + assert client.get(reverse("litefile_staff:account", args=[owner.pk])).status_code == 404 + from django.test import Client + + csrf_client = verified_client(Client(enforce_csrf_checks=True), administrator) + assert csrf_client.post(url, {"action": "external_resolved", "resolved": "yes"}).status_code == 403 + + +def test_superuser_can_onboard_staff_with_multiple_scoped_roles(client, django_user_model): + root = django_user_model.objects.create_superuser( + username="root", email="root@invalid.example", password="a-strong-local-password" + ) + client.force_login(root, backend="django.contrib.auth.backends.ModelBackend") + assert client.get(reverse("litefile_staff:efile_userprofile_add")).status_code == 302 + verified_client(client, root) + response = client.post( + reverse("litefile_staff:efile_userprofile_add"), + { + "username": "new-staff", + "password1": "a-different-strong-password", + "password2": "a-different-strong-password", + "usable_password": "true", + }, + ) + assert response.status_code == 302 + staff = django_user_model.objects.get(username="new-staff") + assert staff.is_staff and not staff.is_superuser and staff.check_password("a-different-strong-password") + for role in ("accounts", "analytics"): + assert ( + client.post( + reverse("litefile_staff:efile_staffrolegrant_add"), + {"user": staff.pk, "jurisdiction": "vermont", "role": role}, + ).status_code + == 302 + ) + assert staff.staff_roles.count() == 2 + url = reverse("litefile_staff:authenticator", args=[staff.pk]) + assert client.get(url).status_code == 200 + assert not TOTPDevice.objects.filter(user=staff).exists() + response = client.post(url, {"confirmed": "yes"}) + assert response.status_code == 200 and b"otpauth://" in response.content + assert TOTPDevice.objects.filter(user=staff, confirmed=True).count() == 1 + + +def test_extraction_and_stale_upload_cannot_restore_frozen_data(owner, administrator, settings): + settings.LITEFILE_PRIVACY_DELETION_ENABLED = True + draft = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + document = FilingDocument.objects.create(draft=draft, role="lead", s3_key="prepared") + job = DocumentExtraction.objects.create( + document=document, + status="processing", + claim_token=uuid.uuid4(), + lease_expires_at=timezone.now() + timedelta(minutes=15), + ) + request = create_request(administrator, owner, account_wide=True) + verify_request(request.pk, administrator) + handler = Mock() + assert process_request(request.pk, administrator, handler=handler).outcome == "extraction_worker_in_flight" + handler.erase_file.assert_not_called() + FilingDraft.objects.filter(pk=draft.pk).update(deletion_pending=True) + assert claim_next_extraction() is None + with patch("efile.services.document_extractions.S3UploadHandler") as storage: + assert process_document_extraction(job.pk, job.claim_token) is None + storage.assert_not_called() + with patch("efile.services.document_uploads.store_prepared_document") as prepare: + with pytest.raises(ValueError, match="no longer available"): + upload_files(draft, [], "illinois") + prepare.assert_not_called() + + +def test_failed_upload_retains_ownership_for_later_erasure(owner): + draft = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + handler = Mock() + handler.delete_file.return_value = {"success": False} + + def partial_upload(*args, keys, **kwargs): + keys.append("partially-saved-original") + raise ValueError("Preparation failed") + + with ( + patch("efile.services.document_uploads.S3UploadHandler", return_value=handler), + patch("efile.services.document_uploads.store_prepared_document", side_effect=partial_upload), + ): + with pytest.raises(ValueError, match="Preparation failed"): + upload_files(draft, [Mock(name="upload")], "illinois") + assert StoredUpload.objects.filter(draft=draft, key="partially-saved-original").exists() + assert not draft.documents.exists() + + +def test_known_case_and_plan_aliases_do_not_inflate_matters(owner, settings): + settings.LITEFILE_ANALYTICS_ENABLED = True + plan = FilingPlan.objects.create(user=owner, jurisdiction="illinois", title="Matter") + first = FilingDraft.objects.create(user=owner, jurisdiction="illinois", court_code="court", plan=plan) + first.mark_submitted({}) + plan.case_tracking_id = "known-case" + plan.save() + later = FilingDraft.objects.create( + user=owner, jurisdiction="illinois", court_code="court", plan=plan, previous_case_id="known-case" + ) + later.mark_submitted({}) + other_plan = FilingPlan.objects.create(user=owner, jurisdiction="illinois", title="Same matter") + third = FilingDraft.objects.create( + user=owner, jurisdiction="illinois", court_code="court", plan=other_plan, previous_case_id="known-case" + ) + third.mark_submitted({}) + record_matter(third) + assert UsageEvent.objects.filter(metric="matter_first_used").count() == 1 + drain_events() + assert UsageCounter.objects.get(metric="matter_first_used", granularity="month", dimension="all").count == 1 + + +def test_outbox_rejects_free_text_in_an_allowed_dimension(owner, settings): + settings.LITEFILE_ANALYTICS_ENABLED = True + draft = FilingDraft.objects.create(user=owner, jurisdiction="illinois") + event = UsageEvent.objects.get(draft=draft) + event.dimensions["case_type"] = owner.email + event.save() + with pytest.raises(ValueError, match="Disallowed usage value"): + count_event(event.pk) diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 03407c59..fdbfd4fe 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -3,6 +3,7 @@ from django.urls import include, path from django.views.i18n import JavaScriptCatalog +from efile.staff_admin import staff_site from efile.utils.config_loader import config_loader from efile.utils.jurisdiction_stuff import has_jurisdiction_login @@ -65,6 +66,7 @@ def jurisdiction_homepage(request, jurisdiction): urlpatterns = [ + path(f"{settings.LITEFILE_STAFF_PATH}/", staff_site.urls), path("api/handoffs/v1/", external_handoff, name="external_handoff"), path("api/handoffs/v1/documents/", replace_documents, name="handoff_replace_documents"), path("handoff/claim//", handoff_claim, name="handoff_claim"), diff --git a/efile_app/efile/utils/proxy_connection.py b/efile_app/efile/utils/proxy_connection.py index 01886727..4a84851e 100644 --- a/efile_app/efile/utils/proxy_connection.py +++ b/efile_app/efile/utils/proxy_connection.py @@ -33,15 +33,15 @@ def auth_with_tyler_api(username, password, jurisdiction): try: response = requests.post(url, json=payload, headers=headers, timeout=10) except requests.RequestException as error: - logger.error("Auth endpoint failed: %s - %s", url, error) + logger.error("Court authentication endpoint unavailable") raise EfspUnavailable(str(error)) from error - logger.info("Auth API response: status=%s url=%s", response.status_code, url) + logger.info("Court authentication endpoint status: %s", response.status_code) if response.status_code == 200: return response.json() if response.status_code >= 500: raise EfspUnavailable(f"Auth endpoint returned {response.status_code}") - logger.warning("Auth endpoint returned status %s for user %s", response.status_code, username) + logger.warning("Court authentication endpoint rejected credentials: %s", response.status_code) return None diff --git a/efile_app/efile/utils/s3_upload_handler.py b/efile_app/efile/utils/s3_upload_handler.py index 4db0f4c9..40b9569d 100644 --- a/efile_app/efile/utils/s3_upload_handler.py +++ b/efile_app/efile/utils/s3_upload_handler.py @@ -72,8 +72,8 @@ def _initialize_s3_client(self): ), ) - except Exception as e: - logger.error("Failed to initialize S3 client: %s", e) + except Exception: + logger.error("Document storage initialization failed") self.s3_client = None def upload_file(self, file_obj, file_type="document", metadata=None): @@ -132,7 +132,7 @@ def upload_file(self, file_obj, file_type="document", metadata=None): # Generate the URL that can be used for efile submission file_url = self._generate_file_url(s3_key) - logger.info(f"Successfully uploaded file {file_obj.name} to S3: {s3_key}") + logger.info("Document upload succeeded") return { "success": True, @@ -146,11 +146,11 @@ def upload_file(self, file_obj, file_type="document", metadata=None): except ClientError as e: error_msg = f"Failed to upload file to S3: {e}" - logger.error(error_msg) + logger.error("Document storage request failed") return {"success": False, "error": error_msg} except Exception as e: error_msg = f"Unexpected error during S3 upload: {e}" - logger.error(error_msg) + logger.error("Document storage request failed unexpectedly") return {"success": False, "error": error_msg} def _generate_file_url(self, s3_key, expiration=3600): @@ -173,8 +173,8 @@ def _generate_file_url(self, s3_key, expiration=3600): return presigned_url - except ClientError as e: - logger.error(f"Failed to generate presigned URL: {e}") + except ClientError: + logger.error("Document URL generation failed") raise def get_public_url(self, s3_key, expiration=604800): # 7 days default @@ -200,14 +200,55 @@ def delete_file(self, s3_key): try: self.s3_client.delete_object(Bucket=self.bucket_name, Key=s3_key) - logger.info(f"Successfully deleted file from S3: {s3_key}") + logger.info("Document deletion succeeded") return {"success": True} except ClientError as e: error_msg = f"Failed to delete file from S3: {e}" - logger.error(error_msg) + logger.error("Document deletion failed") return {"success": False, "error": error_msg} + def erase_file(self, s3_key): + """Permanently erase this exact key, including noncurrent versions. + + Fail closed when version listing/deletion is denied or object lock blocks + erasure. Never mistake a delete marker for permanent deletion. + """ + if not self._ensure_initialized(): + return {"success": False} + try: + versioning = self.s3_client.get_bucket_versioning(Bucket=self.bucket_name) + if versioning.get("Status") in {"Enabled", "Suspended"}: + objects = [] + pages = self.s3_client.get_paginator("list_object_versions").paginate( + Bucket=self.bucket_name, Prefix=s3_key + ) + for page in pages: + objects.extend( + {"Key": s3_key, "VersionId": item["VersionId"]} + for item in [*page.get("Versions", []), *page.get("DeleteMarkers", [])] + if item["Key"] == s3_key + ) + for start in range(0, len(objects), 1000): + result = self.s3_client.delete_objects( + Bucket=self.bucket_name, Delete={"Objects": objects[start : start + 1000], "Quiet": True} + ) + if result.get("Errors"): + return {"success": False} + for page in self.s3_client.get_paginator("list_object_versions").paginate( + Bucket=self.bucket_name, Prefix=s3_key + ): + if any( + item["Key"] == s3_key for item in [*page.get("Versions", []), *page.get("DeleteMarkers", [])] + ): + return {"success": False} + else: + self.s3_client.delete_object(Bucket=self.bucket_name, Key=s3_key) + return {"success": True} + except Exception: + logger.warning("Privacy object erasure failed; restricted retry manifest retained") + return {"success": False} + def download_file(self, s3_key, destination): """Download a private object to a local path for background processing.""" if not self._ensure_initialized(): @@ -218,7 +259,7 @@ def download_file(self, s3_key, destination): return {"success": True} except ClientError as error: error_msg = f"Failed to download file from S3: {error}" - logger.error(error_msg) + logger.error("Document download failed") return {"success": False, "error": error_msg} def _get_file_extension(self, filename): diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py index 049c3a8e..db546f3d 100644 --- a/efile_app/efile/views/handoff.py +++ b/efile_app/efile/views/handoff.py @@ -200,7 +200,7 @@ def handoff_claim(request, token): def _owned(request, draft_id): if not request.user.is_authenticated: raise HandoffError("Sign in to open this draft.", status=401) - return get_object_or_404(FilingDraft, pk=draft_id, user=request.user) + return get_object_or_404(FilingDraft, pk=draft_id, user=request.user, deletion_pending=False) def _issue_links(draft): @@ -381,6 +381,8 @@ def replace_documents(request): digest = fingerprint(payload) with transaction.atomic(): draft = get_object_or_404(FilingDraft.objects.select_for_update(), pk=scope["draft"]) + if draft.deletion_pending: + raise HandoffError("This draft is unavailable during data deletion.", status=409) receipt = receipt_for(draft) if ( not receipt @@ -427,6 +429,7 @@ def replace_documents(request): row.size = uploaded["size"] row.original_s3_key = uploaded["original_s3_key"] row.preparation = uploaded["preparation"] + row.upload_has_form_fields = uploaded.get("upload_has_form_fields") row.preparation_reviewed_at = None row.save( update_fields=[ @@ -438,6 +441,7 @@ def replace_documents(request): "size", "original_s3_key", "preparation", + "upload_has_form_fields", "preparation_reviewed_at", "updated_at", ] diff --git a/efile_app/efile/views/review.py b/efile_app/efile/views/review.py index 2917f15e..92b9b293 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -62,6 +62,9 @@ def case_review(request, jurisdiction): return redirect("payment", jurisdiction=jurisdiction) question_labels = {question["name"]: question["label"] for question in get_case_questions(draft)} + from efile.services.analytics import record_event + + record_event(draft, "review") question_answers = [ { "label": question_labels.get(key, key.replace("_", " ").title()), diff --git a/efile_app/efile/views/submission.py b/efile_app/efile/views/submission.py index 32221120..553e6b9f 100644 --- a/efile_app/efile/views/submission.py +++ b/efile_app/efile/views/submission.py @@ -1,5 +1,6 @@ import json import logging +import uuid from django.db import transaction from django.http import JsonResponse @@ -46,8 +47,13 @@ def _claim_for_submission(draft: FilingDraft, acceptance: dict) -> bool: status=FilingDraft.Status.SUBMITTING, disclaimer_acceptance=acceptance, updated_at=timezone.now(), + submission_operation=uuid.uuid4(), ) if claimed: + from efile.services.analytics import record_event + + draft.refresh_from_db(fields=["submission_operation"]) + record_event(draft, "submission_attempt", operation=draft.submission_operation) draft.status = FilingDraft.Status.SUBMITTING draft.disclaimer_acceptance = acceptance return bool(claimed) @@ -157,6 +163,9 @@ def submit_final_filing(request): request.session.modified = True clear_current_draft(request) elif _failed_before_external_call(payload) or _confirmed_api_rejection(payload): + from efile.services.analytics import record_event + + record_event(draft, "submission_error", operation=draft.submission_operation) # Nothing was filed (rejected before the call, or the API refused it), # so it is safe to return the draft to DRAFT for a corrected retry. _release_claim(draft) diff --git a/efile_app/efile/views/upload_documents.py b/efile_app/efile/views/upload_documents.py index 8968c594..7d2ebcbf 100644 --- a/efile_app/efile/views/upload_documents.py +++ b/efile_app/efile/views/upload_documents.py @@ -157,7 +157,7 @@ def upload_documents(request, jurisdiction): try: upload_data = upload_files(draft, uploaded_files, jurisdiction) except ValueError as error: - logger.exception("Upload failed for draft %s", draft.pk) + logger.warning("Document upload failed") return JsonResponse({"success": False, "error": str(error)}, status=400) return JsonResponse( { diff --git a/efile_app/efile/views/waiver_documents.py b/efile_app/efile/views/waiver_documents.py index 43a112a4..485f3287 100644 --- a/efile_app/efile/views/waiver_documents.py +++ b/efile_app/efile/views/waiver_documents.py @@ -51,7 +51,11 @@ def waiver_documents(request, jurisdiction): raise ValueError("Document storage is not available. Try again.") with transaction.atomic(): draft = FilingDraft.objects.select_for_update().get(pk=draft.pk) - if draft.status not in ACTIVE_DRAFT_STATUSES or data.get("fee_inputs_token") != fee_inputs_token(draft): + if ( + draft.deletion_pending + or draft.status not in ACTIVE_DRAFT_STATUSES + or data.get("fee_inputs_token") != fee_inputs_token(draft) + ): return JsonResponse( {"error": "This filing changed. Reload this page before adding a document."}, status=409 ) diff --git a/efile_app/pyproject.toml b/efile_app/pyproject.toml index c3ea18eb..54213fa1 100644 --- a/efile_app/pyproject.toml +++ b/efile_app/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "suffolklitlab-macourts @ git+https://github.com/SuffolkLITLab/MACourts@main", # Vermont Superior Court unit lookup by town, county, or ZIP. "suffolklitlab-vtcourts @ git+https://github.com/SuffolkLITLab/VTCourts@main", + "django-otp>=1.7.0,<2", ] [build-system] diff --git a/efile_app/uv.lock b/efile_app/uv.lock index debe4276..5af1b657 100644 --- a/efile_app/uv.lock +++ b/efile_app/uv.lock @@ -553,6 +553,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" }, ] +[[package]] +name = "django-otp" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/88/f98f860f5c209bfa1980c63ff38128b50166bf396d28c60d93c42bdb08de/django_otp-1.7.3.tar.gz", hash = "sha256:3ce501173bf1b936146ffe5ff0cfdae1a0c7eb46cce1e9e3fdddd984d993c74f", size = 78996, upload-time = "2026-09-06T16:44:21.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a5/bb9d7b83b0ebf62603d3ff707cf492f75ec60077495b885ecbdf09774f67/django_otp-1.7.3-py3-none-any.whl", hash = "sha256:7d52fb0f76c4c1c86cbc71c196d3f79941b0b84aff980e45193bef89a70a7a12", size = 73702, upload-time = "2026-09-06T16:44:19.807Z" }, +] + [[package]] name = "django-stubs" version = "5.2.9" @@ -972,6 +984,7 @@ dependencies = [ { name = "boto3" }, { name = "dj-database-url" }, { name = "django" }, + { name = "django-otp" }, { name = "djlint" }, { name = "docx2python" }, { name = "gunicorn" }, @@ -1013,6 +1026,7 @@ requires-dist = [ { name = "boto3", specifier = ">=1.40.12" }, { name = "dj-database-url", specifier = ">=2.2" }, { name = "django", specifier = "==5.2.17" }, + { name = "django-otp", specifier = ">=1.7.0,<2" }, { name = "djlint", specifier = ">=1.44.2" }, { name = "docx2python", specifier = ">=3.5,<4" }, { name = "gunicorn", specifier = ">=22.0" },