From 61b0e51cdf945dce9df8e9ccc170fb8758eab27c Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Sun, 30 Aug 2026 18:21:26 +0530 Subject: [PATCH 1/7] fix(scripts): record the executable bit in git so the tarball ships it This repository has core.fileMode = false, so chmod +x never reaches a commit. The mode guard measured os.access() on the working tree, which reports a local bit no clone and no release tarball ever sees. That blind spot voided a production fix. scripts/restore.sh was chmod'd 0755 and guarded on 2026-08-25, the guard went green, and `git archive HEAD` still shipped it 0644 - so import-backup.sh:134 still died at `exec .../restore.sh` with "Permission denied", after recording the restore intent and releasing its lock. install.sh, import-backup.sh and verify-release-images.sh shipped 0644 too; only spaceworks-compose.sh was ever fixed, via git update-index. Modes are now set with git update-index --chmod=+x, and the guard reads `git ls-files -s` because for every script install.sh does not chmod at unpack time, git's mode IS the shipped mode. Verified by clearing the bit in the index while the working tree stayed 755: the guard fails, where before it passed. Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5 (1M context) --- backend/tests/test_privileged_script_modes.py | 45 +++++++++++++++---- install.sh | 0 scripts/dev-docker.sh | 0 scripts/import-backup.sh | 0 scripts/restore.sh | 0 scripts/verify-release-images.sh | 0 6 files changed, 37 insertions(+), 8 deletions(-) mode change 100644 => 100755 install.sh mode change 100644 => 100755 scripts/dev-docker.sh mode change 100644 => 100755 scripts/import-backup.sh mode change 100644 => 100755 scripts/restore.sh mode change 100644 => 100755 scripts/verify-release-images.sh diff --git a/backend/tests/test_privileged_script_modes.py b/backend/tests/test_privileged_script_modes.py index 82243da2..62d0b680 100644 --- a/backend/tests/test_privileged_script_modes.py +++ b/backend/tests/test_privileged_script_modes.py @@ -11,8 +11,8 @@ """ from pathlib import Path -import os import re +import subprocess import pytest @@ -35,6 +35,36 @@ def _shipped_shell_scripts(): return sorted(ROOT.glob("scripts/*.sh")) + [ROOT / "setup.sh", ROOT / "install.sh"] +# The executable bit must be read from GIT, not the working tree. This repository has +# `core.fileMode = false`, so `chmod +x` never reaches a commit and `os.access()` reports +# a local bit that no clone and no release tarball will ever see. That blind spot already +# cost a production fix: `scripts/restore.sh` was chmod'd 0755 and guarded here on +# 2026-08-25, the guard went green, and `git archive HEAD` still shipped it 0644 - so +# `import-backup.sh` still died at `exec .../restore.sh` with "Permission denied", after +# recording the restore intent and releasing its lock. `install.sh` unpacks the GitHub +# tarball with git's modes and chmods only itself and the compose wrapper, so for every +# other script git's mode IS the shipped mode. Set it with `git update-index --chmod=+x`. +def _git_file_mode(name): + completed = subprocess.run( + ["git", "ls-files", "-s", "--", name], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + assert completed.stdout.strip(), f"{name} is not tracked by git" + return completed.stdout.split(None, 1)[0] + + +def _assert_ships_executable(name): + mode = _git_file_mode(name) + assert mode == "100755", ( + f"{name} is recorded in git as {mode}, so the release tarball ships it " + "without its executable bit and running it gives 'Permission denied'. " + f"Fix with: git update-index --chmod=+x {name}" + ) + + @pytest.mark.parametrize( "name", ["install.sh"] @@ -42,10 +72,7 @@ def _shipped_shell_scripts(): ) def test_operator_entry_point_is_executable(name): """The curl entry point and every privileged shell script ship executable.""" - assert os.access(ROOT / name, os.X_OK), ( - f"{name} is shipped without its executable bit; an operator running it " - "directly gets 'Permission denied'." - ) + _assert_ships_executable(name) def test_no_shipped_script_directly_executes_a_non_executable_sibling(): @@ -56,8 +83,10 @@ def test_no_shipped_script_directly_executes_a_non_executable_sibling(): continue for target in _DIRECT_EXECUTION.findall(script.read_text(encoding="utf-8")): checked += 1 - assert os.access(ROOT / target, os.X_OK), ( - f"{script.name} directly executes {target}, which is not executable; " - "that handoff dies with 'Permission denied'." + mode = _git_file_mode(target) + assert mode == "100755", ( + f"{script.name} directly executes {target}, which git records as " + f"{mode}; that handoff dies with 'Permission denied' in the release " + f"tarball. Fix with: git update-index --chmod=+x {target}" ) assert checked, "The direct-execution scan matched nothing; the pattern has rotted." diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 diff --git a/scripts/dev-docker.sh b/scripts/dev-docker.sh old mode 100644 new mode 100755 diff --git a/scripts/import-backup.sh b/scripts/import-backup.sh old mode 100644 new mode 100755 diff --git a/scripts/restore.sh b/scripts/restore.sh old mode 100644 new mode 100755 diff --git a/scripts/verify-release-images.sh b/scripts/verify-release-images.sh old mode 100644 new mode 100755 From 9e496997707f9d96e05e73e85b1fc6f001da921e Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Sun, 30 Aug 2026 19:45:41 +0530 Subject: [PATCH 2/7] fix(requests): let public request submission work with the membership module off setup.sh ships MSPROFILE="recommended", whose extras include member_accounts but not membership. request_workflow is core and cannot be turned off. But RequestSubmitView called require_active_member_presence unconditionally, and that guard hard-requires an active MakerspaceMembership row without ever consulting the module registry. So a fresh self-host install that keeps the default profile shipped unable to accept a public borrow request: a makerspace built from profile_modules('recommended') returned 403 membership_required to an authenticated user. The cloud and full profiles include membership and were unaffected, as is any operator who ticked it on in the setup checklist. Only RequestSubmitView changes. The membership row is the ONLY thing binding a user to a makerspace in that guard - is_servable, is_authenticated, is_active and access_status are all global - so relaxing every caller would let any account act at any membership-off space. Submitting a request is a proposal staff must accept; the other surfaces move hardware or reserve capacity, and tests assert they still refuse. Waivers cannot be enforced with membership off: acceptance is stored on MakerspaceMembership and accepting one requires an active membership. In that configuration the flow is public request -> staff accept. Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex GPT-5.6 --- .../apps/hardware_requests/public_views.py | 13 +- backend/apps/presence/guard.py | 56 +++++-- backend/tests/presence/test_guard_m3.py | 18 ++ .../test_request_membership_module_b1.py | 158 ++++++++++++++++++ docs/INVARIANTS.md | 14 +- 5 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 backend/tests/test_request_membership_module_b1.py diff --git a/backend/apps/hardware_requests/public_views.py b/backend/apps/hardware_requests/public_views.py index 367a2eb2..a0213b1e 100644 --- a/backend/apps/hardware_requests/public_views.py +++ b/backend/apps/hardware_requests/public_views.py @@ -23,9 +23,9 @@ ) from apps.inventory.models import InventoryProduct from apps.makerspaces.lookup import get_public_makerspace -from apps.makerspaces.servability import servable_queryset from apps.makerspaces.platform import module_enabled -from apps.presence.guard import require_active_member_presence +from apps.makerspaces.servability import servable_queryset +from apps.presence.guard import require_active_account, require_active_member_presence from apps.openapi import ( PUBLIC_API_AUTH_PARAMETERS, PUBLIC_REQUEST_STATUS_EXAMPLE, @@ -49,7 +49,14 @@ class RequestSubmitView(APIView): def post(self, request, makerspace_slug, *args, **kwargs): makerspace = get_public_makerspace(makerspace_slug) _require_module(makerspace, "request_workflow") - require_active_member_presence(request.user, makerspace) + if module_enabled(makerspace, "membership"): + require_active_member_presence(request.user, makerspace) + else: + # Waiver acceptance lives on MakerspaceMembership and cannot be recorded + # with membership off. In this configuration the flow is public request -> + # STAFF ACCEPT, and that staff acceptance is the control, so no waiver is + # enforced at proposal time. + require_active_account(request.user, makerspace) # Honeypot check FIRST, on the raw payload: a bot that fills `website` must get a # normal-looking success even if it also garbled a required field — otherwise a # validation error would reveal that the honeypot was the rejection trigger. diff --git a/backend/apps/presence/guard.py b/backend/apps/presence/guard.py index 21f6194e..b0a13481 100644 --- a/backend/apps/presence/guard.py +++ b/backend/apps/presence/guard.py @@ -2,8 +2,8 @@ from django.utils import timezone -from apps.accounts.models import User from apps.accounts.claim_sessions import claim_context +from apps.accounts.models import User from apps.makerspaces.models import MakerspaceMembership, MakerspaceWaiver from apps.makerspaces.servability import is_servable from apps.makerspaces.waiver_state import current_acceptance @@ -28,12 +28,26 @@ class PresenceRequired(Exception): @dataclass(frozen=True) class ActiveMemberPresence: - membership: MakerspaceMembership + # Membership is absent only for the request-proposal path when the makerspace has + # deliberately disabled its community membership module. + membership: MakerspaceMembership | None accepted_waiver: MakerspaceWaiver | None - # None only when the deployment has tombstoned check-in; no caller reads it. + # Also None for the identity-only request path; no current caller reads it there. session: PresenceSession | None +def require_active_account(user, makerspace): + """Require a servable makerspace and an unrestricted authenticated account. + + This is deliberately narrower than `require_active_member`: public borrow requests + are proposals that staff must accept, so a makerspace without the community + membership module can admit them without inventing a tenant-binding membership row. + Hardware and facility actions must keep using the member guards below. + """ + _require_active_identity(user, makerspace) + return ActiveMemberPresence(None, None, None) + + def require_active_member(user, makerspace): """Identity, membership and waiver -- the half that is not about being here. @@ -49,14 +63,7 @@ def require_active_member(user, makerspace): Keeping it as the single implementation of the membership and waiver rules is the point: two copies would drift, and the copy that drifted would be an auth rule. """ - if not is_servable(makerspace) or not ( - user - and user.is_authenticated - and user.pk - and user.is_active - and user.access_status == User.AccessStatus.ACTIVE - ): - raise MemberPresenceRequired() + _require_active_identity(user, makerspace) membership = MakerspaceMembership.objects.filter( user=user, makerspace=makerspace, status="active" ).select_related("accepted_waiver").first() @@ -73,6 +80,17 @@ def require_active_member(user, makerspace): return ActiveMemberPresence(membership, waiver, None) +def _require_active_identity(user, makerspace): + if not is_servable(makerspace) or not ( + user + and user.is_authenticated + and user.pk + and user.is_active + and user.access_status == User.AccessStatus.ACTIVE + ): + raise MemberPresenceRequired() + + def require_active_member_presence(user, makerspace): """Membership, then waiver, then an open check-in session. @@ -81,13 +99,15 @@ def require_active_member_presence(user, makerspace): changes behaviour instead of only removing a surface, so it is worth being explicit about why. - Six member-facing surfaces call this as a bare precondition -- self-checkout, staff - direct handout, public request submit, public booking, and the two public - machine-service surfaces. A deployment that does not ship check-in has no session for - any of them to find, so leaving the requirement hard would not make those flows - stricter, it would make every one of them refuse forever. That is a broken install, - which is exactly the outcome `separability.E007` exists to reject; a tombstone is - supposed to yield a smaller system, not a stuck one. + Member-facing hardware and facility surfaces call this as a bare precondition -- + self-checkout, staff direct handout, public booking, and the two public + machine-service surfaces. Public request submission also calls it while the + membership module is enabled; with that module off, the staff-reviewed proposal + path uses `require_active_account` instead. A deployment that does not ship check-in + has no session for any guarded surface to find, so leaving the requirement hard + would not make those flows stricter, it would make every one of them refuse forever. + That is a broken install, which is exactly the outcome `separability.E007` exists to + reject; a tombstone is supposed to yield a smaller system, not a stuck one. Membership and the waiver are still enforced, so the identity and liability factors are untouched, and so are the Hard Rules' non-negotiables (a box QR scan and an diff --git a/backend/tests/presence/test_guard_m3.py b/backend/tests/presence/test_guard_m3.py index 2db615bd..776db23c 100644 --- a/backend/tests/presence/test_guard_m3.py +++ b/backend/tests/presence/test_guard_m3.py @@ -9,6 +9,7 @@ MemberPresenceRequired, PresenceRequired, WaiverAcceptanceRequired, + require_active_account, require_active_member_presence, ) @@ -20,6 +21,23 @@ def setup_member(space): return user, membership +@pytest.mark.django_db +def test_active_account_guard_keeps_identity_checks_without_requiring_membership(): + space = Makerspace.objects.create(name="Account Guard", slug="account-guard") + user = User.objects.create_user(username="account-only", password="password") + + result = require_active_account(user, space) + + assert result.membership is None + assert result.accepted_waiver is None + assert result.session is None + + user.access_status = User.AccessStatus.RESTRICTED + user.save(update_fields=["access_status"]) + with pytest.raises(MemberPresenceRequired): + require_active_account(user, space) + + @pytest.mark.django_db def test_guard_has_stable_membership_waiver_and_presence_contract(): space = Makerspace.objects.create(name="Guard", slug="guard") diff --git a/backend/tests/test_request_membership_module_b1.py b/backend/tests/test_request_membership_module_b1.py new file mode 100644 index 00000000..fa67c2c3 --- /dev/null +++ b/backend/tests/test_request_membership_module_b1.py @@ -0,0 +1,158 @@ +"""B1: request proposals stay usable when the community membership module is off. + +Only public borrow-request submission gets this narrow exception. The other guarded +surfaces move hardware, reserve facility capacity, or create member participation, so +their tenant-binding MakerspaceMembership requirement is pinned below. +""" + +from datetime import timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.events.models import Event +from apps.hardware_requests.models import HardwareRequest +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace, MakerspaceWaiver +from apps.makerspaces.module_profiles import EVERYTHING, RECOMMENDED, profile_modules + + +pytestmark = pytest.mark.django_db + + +def _space(slug, modules): + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=modules, + enabled_features=["inventory.self_checkout"], + ) + + +def _user(username): + return User.objects.create_user( + username=username, + email=f"{username}@example.test", + display_name=username, + phone="+15550101010", + ) + + +def _client(user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user) + return client + + +def _product(space): + return InventoryProduct.objects.create( + makerspace=space, + name="Logic analyzer", + total_quantity=1, + available_quantity=1, + is_public=True, + ) + + +def _request_payload(product): + return { + "requested_for": "Bench diagnostics", + "items": [{"product_id": product.pk, "quantity": 1}], + } + + +def test_membership_off_allows_an_authenticated_non_member_to_submit_request(): + modules = profile_modules(RECOMMENDED) + assert "membership" not in modules + space = _space("request-membership-off", modules) + product = _product(space) + MakerspaceWaiver.objects.create( + makerspace=space, + is_active=True, + version="1", + body="Staff acceptance controls this proposal path.", + ) + + response = _client(_user("request-outsider")).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + _request_payload(product), + format="json", + ) + + assert response.status_code == 201, response.data + assert HardwareRequest.objects.filter(makerspace=space).count() == 1 + + +def test_membership_on_still_refuses_an_authenticated_non_member(): + space = _space("request-membership-on", profile_modules(EVERYTHING)) + product = _product(space) + + response = _client(_user("request-non-member")).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + _request_payload(product), + format="json", + ) + + assert response.status_code == 403 + assert response.data["code"] == "membership_required" + assert not HardwareRequest.objects.filter(makerspace=space).exists() + + +def test_membership_off_request_submission_still_requires_authentication(): + space = _space("request-anonymous", profile_modules(RECOMMENDED)) + + response = _client().post( + reverse("hardware_requests:request-submit", args=[space.slug]), + _request_payload(_product(space)), + format="json", + ) + + assert response.status_code == 401 + assert not HardwareRequest.objects.filter(makerspace=space).exists() + + +def test_membership_off_does_not_relax_other_physical_action_surfaces(): + """A future consistency cleanup must not widen these cross-tenant actions.""" + modules = [key for key in profile_modules(EVERYTHING) if key != "membership"] + space = _space("physical-actions-stay-member-only", modules) + client = _client(_user("physical-action-outsider")) + urls = [ + reverse("hardware_requests:public-tool-evidence-url", args=[space.slug]), + reverse("hardware_requests:public-tool-checkout", args=[space.slug]), + reverse("hardware_requests:public-tool-return", args=[space.slug]), + reverse("public-machine-service-request-submit", args=[space.slug]), + reverse("public-printer-service-upload", args=[space.slug]), + reverse("public-printer-service-request", args=[space.slug]), + ] + + for url in urls: + response = client.post(url, {}, format="json") + assert response.status_code == 403, (url, response.data) + assert response.data["code"] == "membership_required", (url, response.data) + + +def test_membership_off_does_not_relax_event_registration(): + modules = [key for key in profile_modules(EVERYTHING) if key != "membership"] + space = _space("events-stay-member-only", modules) + starts_at = timezone.now() + timedelta(days=1) + event = Event.objects.create( + makerspace=space, + title="Safety induction", + starts_at=starts_at, + ends_at=starts_at + timedelta(hours=1), + is_public=True, + status=Event.Status.PUBLISHED, + ) + + response = _client(_user("event-outsider")).post( + reverse("public-event-register", args=[space.slug, event.public_token]), + {}, + format="json", + ) + + assert response.status_code == 403 + assert response.data["code"] == "membership_required" diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index 5fb2f909..ba666b1a 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -1632,12 +1632,14 @@ Load-bearing details that carried over unchanged: - **Registering for an event does NOT require a `PresenceSession`; check-in does.** `require_active_member` (identity + membership + waiver) was split out of - `require_active_member_presence`, and **only** `PublicEventRegistrationView` switched. The other - **nine invocations across six surfaces** — self-checkout ×3, direct handout, public request submit, - public booking, and the two machine-service surfaces — still require a session, because those are - hardware and facility acts where "is this member here right now" is the whole question. Signing up - is planning to attend; presence is proven later by the staff-scanned QR, which is stronger evidence - than a self-declared session. + `require_active_member_presence`, and **only** `PublicEventRegistrationView` switched wholesale. The + hardware and facility invocations — self-checkout ×3, direct handout, public booking, and the two + machine-service surfaces — still require a session, because "is this member here right now" is the + whole question. Request submission also keeps that guard while `membership` is enabled; with + `membership` off it uses the separate identity-only guard because a request is only a proposal and + staff acceptance is the control. There is no waiver check on that branch because waiver acceptance + can only be recorded on a membership row. Signing up for an event is planning to attend; presence is + proven later by the staff-scanned QR, which is stronger evidence than a self-declared session. - **`events.member_history.registrations_for_space` is the ONE answer to "which registrations does this member hold in this space"**, shared by the profile counts, the profile's recent-attended list, member activity and the QR lookup. It filters on **durable provenance** From 70a173c5e20d267e4dd324f07edc6a7e1cc9f88b Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Sun, 30 Aug 2026 21:19:53 +0530 Subject: [PATCH 3/7] refactor(makerspaces): move the encrypted-secret accessors to a mixin models_makerspace.py was at 292 lines against the repo's ~300 hard ceiling, and the next phase adds fields to Makerspace. CLAUDE.md requires splitting an at-ceiling file in its own commit before adding to it. The ten encrypt-on-set/decrypt-on-get accessors over five credential columns are one repeated shape with no field definitions, so moving them changes no schema: `makemigrations --check` reports no changes. A mixin rather than free functions because every method reads and writes an attribute of the row it is called on. 260 lines now, and nothing outside the mixin touches the ciphertext columns, which keeps API_CLIENT_ENC_KEY the single decryption door. Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5 (1M context) --- backend/apps/makerspaces/models_makerspace.py | 36 +------------ .../makerspaces/models_makerspace_secrets.py | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 backend/apps/makerspaces/models_makerspace_secrets.py diff --git a/backend/apps/makerspaces/models_makerspace.py b/backend/apps/makerspaces/models_makerspace.py index 89009aa4..d2306977 100644 --- a/backend/apps/makerspaces/models_makerspace.py +++ b/backend/apps/makerspaces/models_makerspace.py @@ -16,7 +16,7 @@ ) from apps.makerspaces.module_registry import default_enabled_module_keys from apps.makerspaces.provenance import validate_actor_snapshot -from apps.makerspaces.secrets import decrypt_value, encrypt_value +from apps.makerspaces.models_makerspace_secrets import MakerspaceSecretsMixin from apps.makerspaces.validators import ( DEFAULT_PRESENCE_PRESETS, validate_google_maps_url, @@ -33,7 +33,7 @@ ) -class Makerspace(models.Model): +class Makerspace(MakerspaceSecretsMixin, models.Model): class LifecycleState(models.TextChoices): ACTIVE = "active", "Active" IMPORTING = "importing", "Importing" @@ -258,35 +258,3 @@ def clean(self): ) if self.geofence_enabled and not self.geofence_effective: raise ValidationError({"geofence_enabled": "Set both latitude and longitude before enabling the geofence."}) - - def set_telegram_bot_token(self, raw): - self.telegram_bot_token = encrypt_value(raw) - - def get_telegram_bot_token(self): - return decrypt_value(self.telegram_bot_token) - - def set_smtp_password(self, raw): - self.smtp_password = encrypt_value(raw) - - def get_smtp_password(self): - return decrypt_value(self.smtp_password) - - def set_slack_webhook_url(self, raw): - self.slack_webhook_url = encrypt_value(raw) - - def get_slack_webhook_url(self): - return decrypt_value(self.slack_webhook_url) - - def set_mattermost_webhook_url(self, raw): - self.mattermost_webhook_url = encrypt_value(raw) - - def get_mattermost_webhook_url(self): - return decrypt_value(self.mattermost_webhook_url) - - def set_discord_webhook_url(self, raw): - self.discord_webhook_url = encrypt_value(raw) - - def get_discord_webhook_url(self): - return decrypt_value(self.discord_webhook_url) - - diff --git a/backend/apps/makerspaces/models_makerspace_secrets.py b/backend/apps/makerspaces/models_makerspace_secrets.py new file mode 100644 index 00000000..24cd566c --- /dev/null +++ b/backend/apps/makerspaces/models_makerspace_secrets.py @@ -0,0 +1,51 @@ +"""Per-makerspace integration secrets: the encrypt-on-set, decrypt-on-get pair. + +Split out of `models_makerspace.py` so that file stays under the ~300-line ceiling +before new fields land on `Makerspace`. These ten methods are one repeated shape over +five columns and carry no field definitions, so moving them changes no schema and +needs no migration. + +They stay a mixin rather than module-level helpers because every one of them reads and +writes an attribute of the row it is called on; `makerspace.get_smtp_password()` is the +call site everywhere, and a free function would have to be handed the instance anyway. +""" + +from apps.makerspaces.secrets import decrypt_value, encrypt_value + + +class MakerspaceSecretsMixin: + """Encrypted accessors for the outbound-integration credentials. + + The columns hold ciphertext; nothing outside these methods should read them + directly, which is what keeps `API_CLIENT_ENC_KEY` the single decryption door. + """ + + def set_telegram_bot_token(self, raw): + self.telegram_bot_token = encrypt_value(raw) + + def get_telegram_bot_token(self): + return decrypt_value(self.telegram_bot_token) + + def set_smtp_password(self, raw): + self.smtp_password = encrypt_value(raw) + + def get_smtp_password(self): + return decrypt_value(self.smtp_password) + + def set_slack_webhook_url(self, raw): + self.slack_webhook_url = encrypt_value(raw) + + def get_slack_webhook_url(self): + return decrypt_value(self.slack_webhook_url) + + def set_mattermost_webhook_url(self, raw): + self.mattermost_webhook_url = encrypt_value(raw) + + def get_mattermost_webhook_url(self): + return decrypt_value(self.mattermost_webhook_url) + + def set_discord_webhook_url(self, raw): + self.discord_webhook_url = encrypt_value(raw) + + def get_discord_webhook_url(self): + return decrypt_value(self.discord_webhook_url) From c391bc64ee79c4136a54a046313eb8af51e09f3f Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Mon, 31 Aug 2026 10:13:40 +0530 Subject: [PATCH 4/7] feat(makerspaces): anonymous-request principal and its credential refusals Substrate for opt-in account-less borrow requests. No endpoint changes yet. HardwareRequest.requester is a non-null PROTECT FK, so an account-less request still needs a User row - but AuditLog.actor is nullable and record(None, ...) is supported, so anonymous actions will record actor=None rather than be attributed to this principal. One User per anonymous request was rejected outright: both FKs are PROTECT, so every such row would be permanently undeletable, making an unauthenticated endpoint an unbounded undeletable-PII growth path. So: one inert principal per makerspace, created lazily under the makerspace select_for_update lock (same order as walk-in creation, for the deadlock its comment describes), with an unusable password, is_active=False and no contact columns. It is identified by a DB-unique OneToOneField, never a username prefix - the invariants pin walkin_/member_ and a backfill that reads them. anonymous_requests_enabled defaults False and is deliberately NOT inferred from the membership module: recommended installs omit that module, so inferring would silently open an unauthenticated write path on every deployment at upgrade. principal_guards refuses the principal for password set/reset, phone and social binding, and access mutation/restore, in shared services that both the REST paths and the Django admin call - a check in one leaves the other open. Without this the principal was convertible into a real account. Four registries had to be fed, one cascading after the next: the Lane D field snapshot, the catalog digest (updated after diffing catalog_schema, which showed exactly the two new fields and no other model), data_export's relational user edges plus classification, and the deployment-global uniqueness policy - the OneToOne creates a global constraint, and the target must create its own principal rather than import an inert source User. Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex GPT-5.6 --- backend/apps/accounts/admin.py | 48 +++++++++++++++---- backend/apps/accounts/principal_guards.py | 30 ++++++++++++ .../apps/accounts/services_password_reset.py | 2 + .../accounts/services_password_reset_drain.py | 2 + backend/apps/accounts/services_phone.py | 14 ++++-- .../apps/accounts/services_social_identity.py | 4 ++ backend/apps/accounts/views_password.py | 14 +++++- .../apps/admin_api/services_user_access.py | 3 ++ backend/apps/admin_api/views_user_access.py | 3 ++ backend/apps/data_export/classification.py | 2 +- backend/apps/data_export/references.py | 1 + .../apps/makerspaces/anonymous_requesters.py | 42 ++++++++++++++++ ...makerspace_anonymous_requester_and_more.py | 26 ++++++++++ backend/apps/makerspaces/models_makerspace.py | 11 +++++ .../tenant_migration/tenant_dump_catalog.py | 2 +- .../tenant_dump_field_snapshot.py | 2 +- .../apps/tenant_migration/unique_values.py | 6 +++ 17 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 backend/apps/accounts/principal_guards.py create mode 100644 backend/apps/makerspaces/anonymous_requesters.py create mode 100644 backend/apps/makerspaces/migrations/0066_makerspace_anonymous_requester_and_more.py diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py index 2313be8e..88fd478f 100644 --- a/backend/apps/accounts/admin.py +++ b/backend/apps/accounts/admin.py @@ -16,6 +16,10 @@ from rest_framework.exceptions import APIException from apps.accounts.models import NativeAppRegistration, User +from apps.accounts.principal_guards import ( + refuse_anonymous_requester_access_mutation, + refuse_anonymous_requester_credential, +) from apps.accounts.transition_services import ( WalkInTransitionError, transition_walk_in_to_account, @@ -78,6 +82,17 @@ class UserAdmin(SuperuserOnlyModelAdmin, DjangoUserAdmin, ModelAdmin): def user_change_password(self, request, id, form_url=""): """Make the inherited password form an explicit account transition.""" user = self.get_object(request, id) + if request.method == "POST" and user is not None: + try: + refuse_anonymous_requester_credential(user) + except APIException as exc: + self.message_user(request, _api_exception_message(exc), level=messages.ERROR) + return HttpResponseRedirect( + reverse( + f"{self.admin_site.name}:{user._meta.app_label}_{user._meta.model_name}_change", + args=(user.pk,), + ) + ) if ( request.method != "POST" or user is None @@ -171,16 +186,28 @@ def restrict_access(self, request, queryset): status = form.cleaned_data["status"] reason = form.cleaned_data["reason"] for user in queryset: - user.access_status = status - user.restriction_reason = reason - user.save(update_fields=["access_status", "restriction_reason"]) - audit.record( - request.user, - "user.access_restricted", - target=user, - meta={"status": user.access_status, "reason": user.restriction_reason}, - ) - success_count += 1 + try: + refuse_anonymous_requester_access_mutation(user) + except APIException as exc: + self.message_user( + request, + f"{user.username}: {_api_exception_message(exc)}", + level=messages.ERROR, + ) + else: + user.access_status = status + user.restriction_reason = reason + user.save(update_fields=["access_status", "restriction_reason"]) + audit.record( + request.user, + "user.access_restricted", + target=user, + meta={ + "status": user.access_status, + "reason": user.restriction_reason, + }, + ) + success_count += 1 self.message_user( request, @@ -196,6 +223,7 @@ def restore_access(self, request, queryset): try: with transaction.atomic(): locked = User.objects.select_for_update().get(pk=user.pk) + refuse_anonymous_requester_access_mutation(locked) if locked.access_status != User.AccessStatus.ACTIVE and locked.is_active: memberships = MakerspaceMembership.objects.select_related( "makerspace" diff --git a/backend/apps/accounts/principal_guards.py b/backend/apps/accounts/principal_guards.py new file mode 100644 index 00000000..dad62255 --- /dev/null +++ b/backend/apps/accounts/principal_guards.py @@ -0,0 +1,30 @@ +"""Relational guards for inert system principals stored in ``accounts.User``.""" + +from rest_framework.exceptions import PermissionDenied + + +ANONYMOUS_REQUESTER_CREDENTIAL_ERROR = ( + "This is an anonymous-request system principal, not an account." +) +ANONYMOUS_REQUESTER_ACCESS_ERROR = ( + "An anonymous-request system principal's access status cannot be changed." +) + + +def is_anonymous_requester(user) -> bool: + """Use the database-unique relationship, never a mutable username convention.""" + if user is None or user.pk is None: + return False + from apps.makerspaces.models import Makerspace + + return Makerspace.objects.filter(anonymous_requester_id=user.pk).exists() + + +def refuse_anonymous_requester_credential(user) -> None: + if is_anonymous_requester(user): + raise PermissionDenied(ANONYMOUS_REQUESTER_CREDENTIAL_ERROR) + + +def refuse_anonymous_requester_access_mutation(user) -> None: + if is_anonymous_requester(user): + raise PermissionDenied(ANONYMOUS_REQUESTER_ACCESS_ERROR) diff --git a/backend/apps/accounts/services_password_reset.py b/backend/apps/accounts/services_password_reset.py index bd03809e..7bfe468a 100644 --- a/backend/apps/accounts/services_password_reset.py +++ b/backend/apps/accounts/services_password_reset.py @@ -11,6 +11,7 @@ from apps.accounts import audit_events from apps.accounts.models import PasswordResetEnvelope, PasswordResetEnvelopeStatus, User +from apps.accounts.principal_guards import is_anonymous_requester from apps.accounts.password_reset_crypto import ( credential_fingerprint, fixed_dummy_digest, @@ -232,6 +233,7 @@ def _credential_state_matches(user, envelope, normalized): and user.is_active and user.access_status == User.AccessStatus.ACTIVE and not user.is_walk_in + and not is_anonymous_requester(user) and current_email == normalized and hmac.compare_digest( envelope.credential_fingerprint, diff --git a/backend/apps/accounts/services_password_reset_drain.py b/backend/apps/accounts/services_password_reset_drain.py index dca4a637..2eebf6ce 100644 --- a/backend/apps/accounts/services_password_reset_drain.py +++ b/backend/apps/accounts/services_password_reset_drain.py @@ -12,6 +12,7 @@ from django.utils import timezone from apps.accounts.models import PasswordResetEnvelope, PasswordResetEnvelopeStatus, User +from apps.accounts.principal_guards import is_anonymous_requester from apps.accounts.password_reset_crypto import ( credential_fingerprint, generate_otp, @@ -234,6 +235,7 @@ def _recoverable(user): and user.is_active and user.access_status == User.AccessStatus.ACTIVE and not user.is_walk_in + and not is_anonymous_requester(user) ) diff --git a/backend/apps/accounts/services_phone.py b/backend/apps/accounts/services_phone.py index b3052cc8..310d9db1 100644 --- a/backend/apps/accounts/services_phone.py +++ b/backend/apps/accounts/services_phone.py @@ -29,6 +29,7 @@ from apps.accounts import audit_events from apps.accounts.models import User +from apps.accounts.principal_guards import is_anonymous_requester from apps.accounts.models_phone import PhoneChallengePurpose, PhoneVerificationChallenge from apps.accounts.phone_numbers import normalize_or_none from apps.integrations.sms import send_sms, sms_configured @@ -150,7 +151,8 @@ def start_link(user, raw_phone): """Send a code to a number an authenticated user wants to attach.""" if not sms_configured(): raise SmsUnavailable - if User.objects.filter(pk=user.pk, is_walk_in=True).exists(): + current = User.objects.filter(pk=user.pk).first() + if current is None or current.is_walk_in or is_anonymous_requester(current): raise serializers.ValidationError({"detail": GENERIC_CONFIRM_ERROR}) phone_e164 = normalize_or_none(raw_phone) if phone_e164 is None: @@ -207,10 +209,14 @@ def confirm_link(user, raw_phone, code): failure = {"detail": GENERIC_CONFIRM_ERROR} # This is the fifth guarded credential-writer for walk-ins. confirm_link is # the chokepoint because a verified phone is itself a login identity. - elif locked_user.is_walk_in: + elif locked_user.is_walk_in or is_anonymous_requester(locked_user): audit_events.record_auth_event( - locked_user, - "member.phone_link_refused_walk_in", + None if not locked_user.is_walk_in else locked_user, + ( + "member.phone_link_refused_walk_in" + if locked_user.is_walk_in + else "member.phone_link_refused_anonymous_requester" + ), target=locked_user, meta={}, ) diff --git a/backend/apps/accounts/services_social_identity.py b/backend/apps/accounts/services_social_identity.py index c976490d..166f5c72 100644 --- a/backend/apps/accounts/services_social_identity.py +++ b/backend/apps/accounts/services_social_identity.py @@ -5,6 +5,7 @@ from apps.accounts.models import User from apps.accounts.models_social import SocialIdentity, SocialSurface +from apps.accounts.principal_guards import is_anonymous_requester class SocialResolutionError(Exception): @@ -62,6 +63,7 @@ def resolve_social_identity( or not verified or matched.email_verified_at is None or matched.is_walk_in + or is_anonymous_requester(matched) ): raise SocialResolutionError("account_link_required", 409) user = matched @@ -107,6 +109,8 @@ def _explicit_link(identity, user, provider, subject): # social-identity link is created through this function. if user.is_walk_in: raise SocialResolutionError("walk_in_record", 403) + if is_anonymous_requester(user): + raise SocialResolutionError("anonymous_requester_record", 403) if identity is not None: if identity.user_id != user.pk: raise SocialResolutionError("identity_conflict", 409) diff --git a/backend/apps/accounts/views_password.py b/backend/apps/accounts/views_password.py index b57625f2..5d75019b 100644 --- a/backend/apps/accounts/views_password.py +++ b/backend/apps/accounts/views_password.py @@ -18,6 +18,7 @@ from rest_framework.views import APIView from apps.accounts.models import User +from apps.accounts.principal_guards import is_anonymous_requester from apps.accounts.password_reset_crypto import normalize_email from apps.accounts.serializers_password_reset import ( ChangePasswordResponseSerializer, @@ -94,7 +95,11 @@ def post(self, request, *args, **kwargs): new_password = serializer.validated_data["new_password"] user = request.user - if user.is_walk_in or not user.check_password(current_password): + if ( + user.is_walk_in + or is_anonymous_requester(user) + or not user.check_password(current_password) + ): raise serializers.ValidationError( {"current_password": "Current password is incorrect."} ) @@ -111,7 +116,11 @@ def post(self, request, *args, **kwargs): with transaction.atomic(): locked = User.objects.select_for_update().get(pk=user.pk) - if locked.is_walk_in or not locked.check_password(current_password): + if ( + locked.is_walk_in + or is_anonymous_requester(locked) + or not locked.check_password(current_password) + ): raise serializers.ValidationError( {"current_password": "Current password is incorrect."} ) @@ -238,6 +247,7 @@ def _verified_legacy_email(user, token, *, expected_email=None): and user.is_active and user.access_status == User.AccessStatus.ACTIVE and not user.is_walk_in + and not is_anonymous_requester(user) ): return None try: diff --git a/backend/apps/admin_api/services_user_access.py b/backend/apps/admin_api/services_user_access.py index 8c7df00f..376abb57 100644 --- a/backend/apps/admin_api/services_user_access.py +++ b/backend/apps/admin_api/services_user_access.py @@ -9,6 +9,7 @@ from apps.accounts import rbac from apps.accounts.models import User +from apps.accounts.principal_guards import refuse_anonymous_requester_credential from apps.admin_api.permissions import hidden_space_manager_reset_break_glass from apps.audit import services as audit from apps.makerspaces.models import MakerspaceMembership @@ -25,6 +26,7 @@ def reset_user_password(actor, target_pk, password=None, data=None): target = _target_for_reset(actor, target_pk, is_superadmin) if target.is_superuser or target.role == User.Role.SUPERADMIN: raise PermissionDenied("Cannot reset a superadmin's password here.") + refuse_anonymous_requester_credential(target) # A walk-in is a person record staff typed at the counter, not an account, and this # service HANDS BACK a usable temporary password -- so without this it is a one-click # way to turn one into a login, available to any space manager in the makerspace and @@ -55,6 +57,7 @@ def reset_user_password(actor, target_pk, password=None, data=None): # Close the stale-read interleaving where the walk-in migration marks this # user after the check above but before this password write. target = User.objects.select_for_update().get(pk=target.pk) + refuse_anonymous_requester_credential(target) if target.is_walk_in: raise PermissionDenied( "This is a walk-in record, not an account. It has no password to reset." diff --git a/backend/apps/admin_api/views_user_access.py b/backend/apps/admin_api/views_user_access.py index e0eb59ef..4b26a6cb 100644 --- a/backend/apps/admin_api/views_user_access.py +++ b/backend/apps/admin_api/views_user_access.py @@ -5,6 +5,7 @@ from rest_framework.views import APIView from apps.accounts.models import User +from apps.accounts.principal_guards import refuse_anonymous_requester_access_mutation from apps.admin_api.permissions import ( IsActiveStaff, IsActiveSuperAdmin, @@ -35,6 +36,7 @@ class RestrictUserView(APIView): def post(self, request, pk, *args, **kwargs): user = get_object_or_404(User, pk=pk) require_user_access_mutation(request.user, user) + refuse_anonymous_requester_access_mutation(user) serializer = RestrictUserSerializer(data=request.data) serializer.is_valid(raise_exception=True) user.access_status = serializer.validated_data["status"] @@ -82,6 +84,7 @@ def post(self, request, pk, *args, **kwargs): with transaction.atomic(): user = get_object_or_404(User.objects.select_for_update(), pk=pk) require_user_access_mutation(request.user, user) + refuse_anonymous_requester_access_mutation(user) if user.access_status != User.AccessStatus.ACTIVE and user.is_active: memberships = user.makerspace_memberships.select_related( "makerspace" diff --git a/backend/apps/data_export/classification.py b/backend/apps/data_export/classification.py index ae2bb6a4..33347de6 100644 --- a/backend/apps/data_export/classification.py +++ b/backend/apps/data_export/classification.py @@ -64,7 +64,7 @@ "maintenance.MaintenanceLog": "id machine performed_by performed_at summary cost parts_note created_at", "maintenance.MaintenanceLogDocument": "id log object_key size_bytes uploaded_by created_at", "maintenance.MaintenanceSchedule": "id machine description interval_days next_due is_active created_by created_at updated_at", - "makerspaces.Makerspace": "id name slug public_code location map_url geofence_latitude geofence_longitude geofence_radius_m geofence_enabled public_inventory_enabled public_stats_enabled public_stats_show_holder_names public_print_status_lookup_policy membership_policy membership_dues_amount referrals_enabled filament_low_stock_threshold_grams superadmin_access_enabled staff_notifications_enabled booking_requester_notifications_enabled logo_key cover_image_key frontend_domain frontend_domain_status domain_verification_token domain_verified_at frontend_domain_changed_at hidden_from_central_directory public_api_key cors_allowed_origins enabled_modules enabled_features resource_limit_overrides storage_bytes_used theme_config branding_config telegram_group_chat_id telegram_bot_token smtp_host smtp_port smtp_username smtp_password smtp_use_tls smtp_use_ssl smtp_from_email slack_webhook_url mattermost_webhook_url discord_webhook_url default_loan_days presence_preset_minutes created_by archived_at archived_by lifecycle_state created_at updated_at", + "makerspaces.Makerspace": "id name slug public_code anonymous_requests_enabled anonymous_requester location map_url geofence_latitude geofence_longitude geofence_radius_m geofence_enabled public_inventory_enabled public_stats_enabled public_stats_show_holder_names public_print_status_lookup_policy membership_policy membership_dues_amount referrals_enabled filament_low_stock_threshold_grams superadmin_access_enabled staff_notifications_enabled booking_requester_notifications_enabled logo_key cover_image_key frontend_domain frontend_domain_status domain_verification_token domain_verified_at frontend_domain_changed_at hidden_from_central_directory public_api_key cors_allowed_origins enabled_modules enabled_features resource_limit_overrides storage_bytes_used theme_config branding_config telegram_group_chat_id telegram_bot_token smtp_host smtp_port smtp_username smtp_password smtp_use_tls smtp_use_ssl smtp_from_email slack_webhook_url mattermost_webhook_url discord_webhook_url default_loan_days presence_preset_minutes created_by archived_at archived_by lifecycle_state created_at updated_at", "makerspaces.MakerspaceMembership": "id makerspace user role assigned_role receives_notifications can_refer can_verify verified_at verified_by status activated_at activated_by revoked_at revoked_by revocation_reason waiver_accepted_at waiver_version_accepted accepted_waiver witnessed_waiver witnessed_waiver_version witnessed_at witnessed_by witnessed_actor_snapshot verified_actor_snapshot activated_actor_snapshot revoked_actor_snapshot created_at", "makerspaces.MakerspaceRole": "id makerspace name slug granted_actions legacy_role is_default is_protected created_at updated_at", "makerspaces.MakerspaceWaiver": "id makerspace body version is_active created_by created_at superseded_at", diff --git a/backend/apps/data_export/references.py b/backend/apps/data_export/references.py index e7c8dbe4..7350f7d9 100644 --- a/backend/apps/data_export/references.py +++ b/backend/apps/data_export/references.py @@ -112,6 +112,7 @@ def require_raw_user(fidelity, *, model, row_pk, field, user_id, existing_user_i ("accounts.MemberClaimCode", "issued_by"), ("accounts.MemberClaimCode", "revoked_by"), ("accounts.OidcBrowserAttempt", "intended_user"), + ("makerspaces.Makerspace", "anonymous_requester"), ("makerspaces.MakerspaceMembership", "user"), ("makerspaces.MakerspaceMembership", "verified_by"), ("makerspaces.MakerspaceMembership", "activated_by"), diff --git a/backend/apps/makerspaces/anonymous_requesters.py b/backend/apps/makerspaces/anonymous_requesters.py new file mode 100644 index 00000000..36fbe958 --- /dev/null +++ b/backend/apps/makerspaces/anonymous_requesters.py @@ -0,0 +1,42 @@ +"""One inert requester principal per makerspace for account-less loan requests.""" + +import uuid + +from django.db import transaction + +from apps.accounts.models import User +from apps.makerspaces.models import Makerspace + +SENTINEL_DISPLAY_NAME = "Anonymous requester (system principal)" + + +def get_or_create_anonymous_requester(makerspace): + """Return the makerspace's sentinel, creating it under the tenant row lock. + + The makerspace lock is deliberately first, matching membership and walk-in + creation. Besides serializing two first requests, this keeps the shared lock order + from turning a concurrent identity operation into a deadlock. + """ + with transaction.atomic(): + locked_space = Makerspace.objects.select_for_update().get(pk=makerspace.pk) + if locked_space.anonymous_requester_id is not None: + return User.objects.get(pk=locked_space.anonymous_requester_id) + + # Reuse the existing member_ allocation namespace. The relationship is + # the marker; inventing an anonymous_* namespace would contradict the migration + # contract that reserves walkin_ and member_ for historical identity discovery. + user = User( + username=f"member_{uuid.uuid4().hex}", + display_name=SENTINEL_DISPLAY_NAME, + email="", + phone="", + phone_e164="", + role=User.Role.REQUESTER, + is_active=False, + ) + user.set_unusable_password() + user.save() + + locked_space.anonymous_requester = user + locked_space.save(update_fields=["anonymous_requester"]) + return user diff --git a/backend/apps/makerspaces/migrations/0066_makerspace_anonymous_requester_and_more.py b/backend/apps/makerspaces/migrations/0066_makerspace_anonymous_requester_and_more.py new file mode 100644 index 00000000..92c5fc25 --- /dev/null +++ b/backend/apps/makerspaces/migrations/0066_makerspace_anonymous_requester_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.8 on 2026-08-31 04:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('makerspaces', '0065_rename_accounts_module_key'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='makerspace', + name='anonymous_requester', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='makerspace', + name='anonymous_requests_enabled', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/apps/makerspaces/models_makerspace.py b/backend/apps/makerspaces/models_makerspace.py index d2306977..516e7468 100644 --- a/backend/apps/makerspaces/models_makerspace.py +++ b/backend/apps/makerspaces/models_makerspace.py @@ -74,6 +74,10 @@ class PublicPrintStatusLookupPolicy(models.TextChoices): geofence_radius_m = models.PositiveIntegerField(default=25, validators=[MinValueValidator(1)]) geofence_enabled = models.BooleanField(default=False) public_inventory_enabled = models.BooleanField(default=True) + # Account-less requests are an unauthenticated write surface, so this is an + # independent opt-in. In particular, it must not follow the membership module: + # recommended installs omit that module and must stay closed after an upgrade. + anonymous_requests_enabled = models.BooleanField(default=False) public_stats_enabled = models.BooleanField(default=False) public_stats_show_holder_names = models.BooleanField(default=False) public_print_status_lookup_policy = models.CharField( @@ -170,6 +174,13 @@ class PublicPrintStatusLookupPolicy(models.TextChoices): on_delete=models.SET_NULL, related_name="created_makerspaces", ) + anonymous_requester = models.OneToOneField( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="+", + ) # Soft-delete state. archived_at IS NOT NULL ⇒ archived (single source of truth; no # separate boolean). Operational reachability also requires lifecycle_state=ACTIVE; # importing/aborted rows stay visible only to narrow import/recovery operations. diff --git a/backend/apps/tenant_migration/tenant_dump_catalog.py b/backend/apps/tenant_migration/tenant_dump_catalog.py index fe4253a1..d06f4cd1 100644 --- a/backend/apps/tenant_migration/tenant_dump_catalog.py +++ b/backend/apps/tenant_migration/tenant_dump_catalog.py @@ -38,7 +38,7 @@ class TenantDumpCatalogError(AssertionError): # SHA-256 of the ordered model/table/field graph produced by ``catalog_schema``. # Updating it is an explicit review act; runtime introspection never blesses drift. -CATALOG_SCHEMA_SHA256 = "0c8a32be94c4f8a4a862818470ae216bbf30ecf5cf9acb201d8d6a8615fd4bf3" +CATALOG_SCHEMA_SHA256 = "0672f2c998f121c610ac372c80e8f87ebd674f78339c57d59de7657b17f3e5cf" def catalog_models(apps_registry=apps): diff --git a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py index a4903ec8..1bc766e6 100644 --- a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py +++ b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py @@ -23,7 +23,7 @@ 'accounts.SocialLoginNonce': frozenset('attestation_challenge client_platform consumed_at created_at delivery device_grant expires_at id nonce_digest origin provider surface'.split()), 'accounts.PlatformSocialAuthSettings': frozenset('apple_key_id apple_native_app_ids apple_private_key apple_service_id apple_team_id google_android_client_id google_ios_client_id google_web_client_id id updated_at'.split()), 'accounts.OidcProvider': frozenset('allow_auto_link client_id created_at display_name id is_enabled issuer jwks_url slug updated_at'.split()), - 'makerspaces.Makerspace': frozenset('archived_at archived_by booking_requester_notifications_enabled branding_config cors_allowed_origins cover_image_key created_at created_by default_loan_days discord_webhook_url domain_verification_token domain_verified_at enabled_features enabled_modules filament_low_stock_threshold_grams frontend_domain frontend_domain_changed_at frontend_domain_status geofence_enabled geofence_latitude geofence_longitude geofence_radius_m hidden_from_central_directory id lifecycle_state location logo_key map_url mattermost_webhook_url membership_dues_amount membership_policy name presence_preset_minutes public_api_key public_code public_inventory_enabled public_print_status_lookup_policy public_stats_enabled public_stats_show_holder_names referrals_enabled resource_limit_overrides slack_webhook_url slug smtp_from_email smtp_host smtp_password smtp_port smtp_use_ssl smtp_use_tls smtp_username staff_notifications_enabled storage_bytes_used superadmin_access_enabled telegram_bot_token telegram_group_chat_id theme_config updated_at'.split()), + 'makerspaces.Makerspace': frozenset('anonymous_requester anonymous_requests_enabled archived_at archived_by booking_requester_notifications_enabled branding_config cors_allowed_origins cover_image_key created_at created_by default_loan_days discord_webhook_url domain_verification_token domain_verified_at enabled_features enabled_modules filament_low_stock_threshold_grams frontend_domain frontend_domain_changed_at frontend_domain_status geofence_enabled geofence_latitude geofence_longitude geofence_radius_m hidden_from_central_directory id lifecycle_state location logo_key map_url mattermost_webhook_url membership_dues_amount membership_policy name presence_preset_minutes public_api_key public_code public_inventory_enabled public_print_status_lookup_policy public_stats_enabled public_stats_show_holder_names referrals_enabled resource_limit_overrides slack_webhook_url slug smtp_from_email smtp_host smtp_password smtp_port smtp_use_ssl smtp_use_tls smtp_username staff_notifications_enabled storage_bytes_used superadmin_access_enabled telegram_bot_token telegram_group_chat_id theme_config updated_at'.split()), 'makerspaces.MakerspaceMembership': frozenset('accepted_waiver activated_actor_snapshot activated_at activated_by assigned_role can_refer can_verify created_at id makerspace receives_notifications revocation_reason revoked_actor_snapshot revoked_at revoked_by role status user verified_actor_snapshot verified_at verified_by waiver_accepted_at waiver_version_accepted witnessed_actor_snapshot witnessed_at witnessed_by witnessed_waiver witnessed_waiver_version'.split()), 'makerspaces.MakerspaceRole': frozenset('created_at granted_actions id is_default is_protected legacy_role makerspace name slug updated_at'.split()), 'makerspaces.MakerspaceWaiver': frozenset('body created_at created_by id is_active makerspace superseded_at version'.split()), diff --git a/backend/apps/tenant_migration/unique_values.py b/backend/apps/tenant_migration/unique_values.py index 561b1172..cfcd8b0d 100644 --- a/backend/apps/tenant_migration/unique_values.py +++ b/backend/apps/tenant_migration/unique_values.py @@ -208,6 +208,12 @@ def _warranty_document_key(row, target, source_value): field="object_key", generator=_maintenance_document_key, ), + ("makerspaces.Makerspace", "field:anonymous_requester"): _policy( + NULL, + "The anonymous-request principal is a per-deployment system row, not a person: " + "the target creates its own lazily on the first account-less request, so importing " + "the source's would carry an inert User the target must never resolve to a human.", + ), ("makerspaces.Makerspace", "field:public_code"): _policy( UniqueValueDisposition.TARGET_CREATION, "Target creation preserves or regenerates the public code.", From 24a8737e249d0162804b8ae4912cae2174d86276 Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Mon, 31 Aug 2026 10:44:09 +0530 Subject: [PATCH 5/7] feat(requests): opt-in account-less public borrow requests RequestSubmitView becomes AllowAny plus an in-view rule, so behaviour is unchanged for every deployment that has not opted in: an anonymous POST to a makerspace without anonymous_requests_enabled still returns exactly today's 401. With the flag on, a stranger submits with contact details and gets a public token. Identity: requester points at the makerspace's inert principal, the human's details go in the existing requester_name/contact_email/contact_phone snapshot columns, and the audit row records actor=None rather than attributing the act to the principal. The principal's username is never copied into the snapshot. Authenticated submissions ignore the contact fields entirely, so a signed-in user cannot use them to impersonate someone. This is an unauthenticated write path, so the limits are part of the feature, not hardening to follow. Each accepted payload creates a request, items, an audit row and a notification fan-out, and a supplied address would otherwise trigger both requester and staff mail - an email-bomb and staff-amplification surface. Hence 2/min and 10/hour per IP, 3/day per email keyed on a fingerprint rather than the address, a ceiling on outstanding anonymous requests, a required Idempotency-Key, and caps on item count, quantity and every text field. Nothing proves a supplied address belongs to the sender and staff acceptance does not prove it either, so the contact is marked unverified and requester lifecycle mail is suppressed until it is. Verified on the running stack, not only in tests: requester is the principal, the principal is inactive with an unusable password, the contact snapshot is correct, no username leaks into the snapshot, and the audit actor is None. Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex GPT-5.6 --- backend/.env.example | 6 + backend/apps/backup/settings_policy.py | 5 +- backend/apps/data_export/classification.py | 2 +- backend/apps/encryption/registry.py | 2 +- backend/apps/hardware_requests/exceptions.py | 13 + .../0024_anonymous_request_submission.py | 42 +++ backend/apps/hardware_requests/models.py | 24 ++ .../apps/hardware_requests/notifications.py | 5 +- .../apps/hardware_requests/public_views.py | 189 +++++++++++-- .../hardware_requests/request_workflow.py | 80 +++++- backend/apps/hardware_requests/serializers.py | 50 +++- backend/apps/hardware_requests/throttles.py | 37 +++ backend/apps/hardware_requests/workflow.py | 4 + .../apps/hardware_requests/workflow_errors.py | 8 + .../apps/makerspaces/anonymous_requesters.py | 10 +- backend/apps/openapi.py | 2 +- .../tenant_dump_field_snapshot.py | 2 +- backend/config/settings.py | 26 ++ backend/tests/encryption/test_write_fence.py | 12 +- .../tests/test_anonymous_request_submit_b2.py | 262 ++++++++++++++++++ ...test_anonymous_request_submit_b2_limits.py | 80 ++++++ 21 files changed, 816 insertions(+), 45 deletions(-) create mode 100644 backend/apps/hardware_requests/migrations/0024_anonymous_request_submission.py create mode 100644 backend/apps/hardware_requests/throttles.py create mode 100644 backend/tests/test_anonymous_request_submit_b2.py create mode 100644 backend/tests/test_anonymous_request_submit_b2_limits.py diff --git a/backend/.env.example b/backend/.env.example index eadcf0b5..456c995f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -107,6 +107,12 @@ PUBLIC_IMAGE_URL_TTL_SECONDS=300 # Public request workflow throttle scopes. THROTTLE_REQUEST_SUBMIT=10/min THROTTLE_REQUEST_STATUS=60/min +THROTTLE_PUBLIC_REQUEST_SUBMIT=10/min +THROTTLE_ANONYMOUS_REQUEST_IP_BURST=2/min +THROTTLE_ANONYMOUS_REQUEST_IP_HOUR=10/hour +THROTTLE_ANONYMOUS_REQUEST_EMAIL=3/day +ANONYMOUS_REQUEST_OUTSTANDING_LIMIT=50 +ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH=128 # Email defaults use the console backend for local development. EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend diff --git a/backend/apps/backup/settings_policy.py b/backend/apps/backup/settings_policy.py index 06f69607..143a5f66 100644 --- a/backend/apps/backup/settings_policy.py +++ b/backend/apps/backup/settings_policy.py @@ -42,6 +42,7 @@ class SettingPolicy: BEHIND_TRUSTED_PROXY CACHE_URL CELERY_BROKER_URL CELERY_RESULT_BACKEND CELERY_TASK_ALWAYS_EAGER CONN_MAX_AGE CORS_ALLOWED_ORIGINS CRON_SECRET CSRF_COOKIE_SECURE CSRF_TRUSTED_ORIGINS DATABASE_URL DATA_EXPORT_DEADLINE_SECONDS +ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH ANONYMOUS_REQUEST_OUTSTANDING_LIMIT DATA_EXPORT_DOWNLOAD_TTL_SECONDS DATA_EXPORT_PAGE_SIZE DATA_EXPORT_RETENTION_SECONDS DEBUG DEFAULT_FROM_EMAIL DEVICE_ANDROID_ATTESTATION_VERIFY_TOKEN DEVICE_ANDROID_ATTESTATION_VERIFY_URL DEVICE_APPLE_ATTESTATION_VERIFY_TOKEN @@ -66,7 +67,9 @@ class SettingPolicy: SOCIAL_AUTH_CLOCK_SKEW_SECONDS SOCIAL_AUTH_JWKS_CACHE_SECONDS SOCIAL_AUTH_JWKS_MAX_BYTES SOCIAL_AUTH_JWKS_TIMEOUT_SECONDS SOCIAL_AUTH_NONCE_TTL_SECONDS SOCIAL_GOOGLE_JWKS_URL STORAGE_PRESIGN_METHOD STRIPE_CONNECT_REDIRECT_URI TELEGRAM_API_URL TELEGRAM_BOT_TOKEN -TELEGRAM_WEBHOOK_SECRET THROTTLE_ARCHIVE_RECIPIENT_VERIFY THROTTLE_BOOKING_SUBMIT THROTTLE_CLIENT_PUBLIC +TELEGRAM_WEBHOOK_SECRET THROTTLE_ANONYMOUS_REQUEST_EMAIL +THROTTLE_ANONYMOUS_REQUEST_IP_BURST THROTTLE_ANONYMOUS_REQUEST_IP_HOUR +THROTTLE_ARCHIVE_RECIPIENT_VERIFY THROTTLE_BOOKING_SUBMIT THROTTLE_CLIENT_PUBLIC THROTTLE_CLIENT_STANDARD THROTTLE_CLIENT_TRUSTED THROTTLE_DATA_EXPORT_CREATE THROTTLE_DEVICE_ATTESTATION_CHALLENGE THROTTLE_DEVICE_LOGIN THROTTLE_DEVICE_LOGIN_USER THROTTLE_DEVICE_REFRESH THROTTLE_EMAIL_VERIFICATION_CONFIRM diff --git a/backend/apps/data_export/classification.py b/backend/apps/data_export/classification.py index 33347de6..1f7691b5 100644 --- a/backend/apps/data_export/classification.py +++ b/backend/apps/data_export/classification.py @@ -21,7 +21,7 @@ "events.EventCollaborator": "id event makerspace status invited_by responded_by created_at responded_at", "events.EventRegistration": "id event checkin_token name email phone member registered_via_makerspace payment_via_makerspace host_waiver host_waiver_accepted_at host_waiver_version_accepted email_exact_hash email_hash_generation custom_answers status created_at", "evidence.EvidencePhoto": "id makerspace evidence_type object_key content_type size_bytes uploaded_by created_at", - "hardware_requests.HardwareRequest": "id makerspace requester requester_username requester_name requester_contact_email requester_contact_phone status requested_for rejection_reason accepted_by accepted_at assigned_box issued_by issued_at return_due_at return_reminder_sent_at issue_evidence issue_remark closed_by closed_at public_token created_at updated_at", + "hardware_requests.HardwareRequest": "id makerspace requester requester_username requester_name requester_contact_email requester_contact_phone requester_contact_verified anonymous_idempotency_key_fingerprint anonymous_payload_fingerprint status requested_for rejection_reason accepted_by accepted_at assigned_box issued_by issued_at return_due_at return_reminder_sent_at issue_evidence issue_remark closed_by closed_at public_token created_at updated_at", "hardware_requests.HardwareRequestItem": "id request product requested_quantity accepted_quantity issued_quantity returned_quantity damaged_quantity missing_quantity needs_fix_quantity", "hardware_requests.HardwareRequestItemAsset": "id request_item asset outcome issued_at returned_at return_event", "hardware_requests.PublicProblemReport": "id makerspace loan request requester note outcome triage_note created_at resolved_at resolved_by", diff --git a/backend/apps/encryption/registry.py b/backend/apps/encryption/registry.py index d83486e2..15afdf6a 100644 --- a/backend/apps/encryption/registry.py +++ b/backend/apps/encryption/registry.py @@ -21,7 +21,7 @@ def _fields(label, names, path, classification, indexes): SOURCE_FIELDS = ( - *_fields("hardware_requests.HardwareRequest", ("requester_username", "requester_name", "requester_contact_email", "requester_contact_phone"), "makerspace_id", "source", ((150, 120, 254, 32), ("none", "bloom", "bloom_exact", "none"))), + *_fields("hardware_requests.HardwareRequest", ("requester_username", "requester_name", "requester_contact_email", "requester_contact_phone"), "makerspace_id", "source", ((150, 200, 254, 32), ("none", "bloom", "bloom_exact", "none"))), *_fields("events.EventRegistration", ("name", "email", "phone"), "event.makerspace_id", "source", ((200, 254, 32), ("none", "event_exact", "none"))), *_fields("bookings.Booking", ("name", "email", "phone", "note"), "space.makerspace_id", "source", ((200, 254, 32, None), ("none", "none", "none", "none"))), *_fields("machines.MachineServiceRequest", ("requester_name", "contact_email", "contact_phone"), "makerspace_id", "source", ((None, None, None), ("bloom", "bloom_exact", "none"))), diff --git a/backend/apps/hardware_requests/exceptions.py b/backend/apps/hardware_requests/exceptions.py index d12eb3a3..15aff5af 100644 --- a/backend/apps/hardware_requests/exceptions.py +++ b/backend/apps/hardware_requests/exceptions.py @@ -12,6 +12,8 @@ EventInvalidTransition, ) from apps.hardware_requests.workflow import ( + AnonymousRequestIdempotencyConflict, + AnonymousRequestOutstandingLimit, BoxUnavailable, BoxValidationError, EvidenceNotUploaded, @@ -49,6 +51,17 @@ class ErrorSerializer(serializers.Serializer): _EXCEPTION_MAP = { + AnonymousRequestIdempotencyConflict: ( + status.HTTP_409_CONFLICT, + "anonymous_request_idempotency_conflict", + "This idempotency key was already used for a different request.", + ), + AnonymousRequestOutstandingLimit: ( + status.HTTP_429_TOO_MANY_REQUESTS, + "anonymous_request_outstanding_limit", + "This makerspace is not accepting more anonymous requests until pending " + "requests are reviewed.", + ), ClaimCodeError: ( status.HTTP_400_BAD_REQUEST, "invalid_claim_code", diff --git a/backend/apps/hardware_requests/migrations/0024_anonymous_request_submission.py b/backend/apps/hardware_requests/migrations/0024_anonymous_request_submission.py new file mode 100644 index 00000000..5a214746 --- /dev/null +++ b/backend/apps/hardware_requests/migrations/0024_anonymous_request_submission.py @@ -0,0 +1,42 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("hardware_requests", "0023_scoped_pii_text_fields")] + + operations = [ + migrations.AddField( + model_name="hardwarerequest", + name="anonymous_idempotency_key_fingerprint", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="hardwarerequest", + name="anonymous_payload_fingerprint", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="hardwarerequest", + name="requester_contact_verified", + field=models.BooleanField(default=True), + ), + migrations.AddIndex( + model_name="hardwarerequest", + index=models.Index( + condition=( + models.Q(status="pending_approval") + & ~models.Q(anonymous_idempotency_key_fingerprint="") + ), + fields=["makerspace", "status"], + name="hwreq_anon_pending_idx", + ), + ), + migrations.AddConstraint( + model_name="hardwarerequest", + constraint=models.UniqueConstraint( + condition=~models.Q(anonymous_idempotency_key_fingerprint=""), + fields=("makerspace", "anonymous_idempotency_key_fingerprint"), + name="uniq_hwreq_anon_idempotency", + ), + ), + ] diff --git a/backend/apps/hardware_requests/models.py b/backend/apps/hardware_requests/models.py index eea25d27..79aae510 100644 --- a/backend/apps/hardware_requests/models.py +++ b/backend/apps/hardware_requests/models.py @@ -30,6 +30,17 @@ class Status(models.TextChoices): requester_name = models.TextField(blank=True, default="") requester_contact_email = models.TextField(blank=True) requester_contact_phone = models.TextField(blank=True) + requester_contact_verified = models.BooleanField(default=True) + anonymous_idempotency_key_fingerprint = models.CharField( + max_length=64, + blank=True, + default="", + ) + anonymous_payload_fingerprint = models.CharField( + max_length=64, + blank=True, + default="", + ) status = models.CharField( max_length=32, choices=Status.choices, @@ -105,6 +116,14 @@ class Meta: status__in=["issued", "partially_returned"], ), ), + models.Index( + fields=["makerspace", "status"], + name="hwreq_anon_pending_idx", + condition=( + models.Q(status="pending_approval") + & ~models.Q(anonymous_idempotency_key_fingerprint="") + ), + ), ] constraints = [ models.UniqueConstraint( @@ -117,6 +136,11 @@ class Meta: ), name="uniq_active_loan_per_box", ), + models.UniqueConstraint( + fields=["makerspace", "anonymous_idempotency_key_fingerprint"], + condition=~models.Q(anonymous_idempotency_key_fingerprint=""), + name="uniq_hwreq_anon_idempotency", + ), ] diff --git a/backend/apps/hardware_requests/notifications.py b/backend/apps/hardware_requests/notifications.py index b6961541..5aa7d483 100644 --- a/backend/apps/hardware_requests/notifications.py +++ b/backend/apps/hardware_requests/notifications.py @@ -125,7 +125,10 @@ def build(): def _email_deliveries(request, requester_key, staff_event): deliveries = [] - if request.requester_contact_email: + if request.requester_contact_email and request.requester_contact_verified: + # Account-less contact is only a claim made by the submitter. Sending any + # lifecycle message before verification would turn this endpoint into an + # email-bomb against arbitrary third parties. Staff delivery remains below. rendered = render_email(request, requester_key) deliveries.append( EmailDelivery( diff --git a/backend/apps/hardware_requests/public_views.py b/backend/apps/hardware_requests/public_views.py index a0213b1e..69a782e5 100644 --- a/backend/apps/hardware_requests/public_views.py +++ b/backend/apps/hardware_requests/public_views.py @@ -1,27 +1,40 @@ +import json import uuid from types import SimpleNamespace -from drf_spectacular.utils import extend_schema +from django.conf import settings +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import generics, status -from rest_framework.exceptions import ValidationError -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.exceptions import NotAuthenticated, Throttled, ValidationError +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView +from apps.accounts.audit_events import fingerprint from apps.apiclients.throttling import ClientTierRateThrottle, MemberPrincipalRateThrottle from apps.hardware_requests import workflow from apps.hardware_requests.models import HardwareRequest +from apps.hardware_requests.request_workflow import ( + RequesterSnapshot, + anonymous_idempotency_replay, +) from apps.hardware_requests.serializers import ( PublicRequestStatusSerializer, RequestSubmitResponseSerializer, RequestSubmitSerializer, ) +from apps.hardware_requests.throttles import ( + AnonymousRequestEmailThrottle, + AnonymousRequestIpBurstThrottle, + AnonymousRequestIpHourThrottle, +) from apps.hardware_requests.view_helpers import ( ERROR_404, PUBLIC_ERROR_RESPONSES, request_queryset, ) from apps.inventory.models import InventoryProduct +from apps.makerspaces.anonymous_requesters import get_or_create_anonymous_requester from apps.makerspaces.lookup import get_public_makerspace from apps.makerspaces.platform import module_enabled from apps.makerspaces.servability import servable_queryset @@ -34,46 +47,108 @@ class RequestSubmitView(APIView): - permission_classes = [IsAuthenticated] - throttle_classes = [MemberPrincipalRateThrottle] + permission_classes = [AllowAny] + # Anonymous throttles are selected inside post(). The authenticated throttle stays + # in APIView.initial() through check_throttles(), preserving the original ordering. + throttle_classes = [] throttle_scope = "public_request_submit" + def check_throttles(self, request): + if request.user.is_authenticated: + _enforce_throttles(request, self, (MemberPrincipalRateThrottle,)) + @extend_schema( tags=["Public requests"], summary="Submit public borrow request", - parameters=PUBLIC_API_AUTH_PARAMETERS, + auth=[{"jwtAuth": []}, {}], + parameters=[ + *PUBLIC_API_AUTH_PARAMETERS, + OpenApiParameter( + name="Idempotency-Key", + type=str, + location=OpenApiParameter.HEADER, + required=False, + description=( + "Required for account-less submissions. Reusing a key with the same " + "payload returns the original request; a different payload is rejected." + ), + ), + ], request=RequestSubmitSerializer, responses={201: RequestSubmitResponseSerializer, **PUBLIC_ERROR_RESPONSES}, examples=[PUBLIC_REQUEST_SUBMIT_EXAMPLE], ) def post(self, request, makerspace_slug, *args, **kwargs): makerspace = get_public_makerspace(makerspace_slug) - _require_module(makerspace, "request_workflow") - if module_enabled(makerspace, "membership"): - require_active_member_presence(request.user, makerspace) - else: - # Waiver acceptance lives on MakerspaceMembership and cannot be recorded - # with membership off. In this configuration the flow is public request -> - # STAFF ACCEPT, and that staff acceptance is the control, so no waiver is - # enforced at proposal time. - require_active_account(request.user, makerspace) - # Honeypot check FIRST, on the raw payload: a bot that fills `website` must get a - # normal-looking success even if it also garbled a required field — otherwise a - # validation error would reveal that the honeypot was the rejection trigger. - if _honeypot_filled(request.data): - decoy = SimpleNamespace( - public_token=uuid.uuid4(), - status=HardwareRequest.Status.PENDING_APPROVAL, - ) - return Response( - RequestSubmitResponseSerializer(decoy).data, - status=status.HTTP_201_CREATED, + anonymous_submission = not request.user.is_authenticated + if anonymous_submission: + if not makerspace.anonymous_requests_enabled: + # Raising DRF's own exception preserves the previous IsAuthenticated + # response body as well as its 401 status for every non-opted-in space. + raise NotAuthenticated() + # Resolving the opt-in flag is unavoidable because disabled spaces must + # retain their 401. From here the raw honeypot precedes module checks, + # throttling, serializer work, product queries and principal creation. + if _honeypot_filled(request.data): + return _honeypot_response() + _require_module(makerspace, "request_workflow") + _enforce_throttles( + request, + self, + (AnonymousRequestIpBurstThrottle, AnonymousRequestIpHourThrottle), ) - serializer = RequestSubmitSerializer(data=request.data) + else: + _require_module(makerspace, "request_workflow") + if module_enabled(makerspace, "membership"): + require_active_member_presence(request.user, makerspace) + else: + # Waiver acceptance lives on MakerspaceMembership and cannot be recorded + # with membership off. In this configuration the flow is public request -> + # STAFF ACCEPT, and staff acceptance is the proposal-time control. + require_active_account(request.user, makerspace) + if _honeypot_filled(request.data): + return _honeypot_response() + + serializer = RequestSubmitSerializer( + data=request.data, + context={"anonymous_submission": anonymous_submission}, + ) serializer.is_valid(raise_exception=True) data = serializer.validated_data data.pop("website", None) + idempotency_key_fingerprint = "" + payload_fingerprint = "" + if anonymous_submission: + request.anonymous_contact_email = data["contact_email"] + _enforce_throttles(request, self, (AnonymousRequestEmailThrottle,)) + idempotency_key = str(request.headers.get("Idempotency-Key", "")).strip() + if not idempotency_key: + raise ValidationError( + {"idempotency_key": "This header is required for anonymous submissions."} + ) + if len(idempotency_key) > settings.ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH: + raise ValidationError( + { + "idempotency_key": ( + "Ensure this header has no more than " + f"{settings.ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH} characters." + ) + } + ) + idempotency_key_fingerprint = fingerprint(idempotency_key) + payload_fingerprint = _anonymous_payload_fingerprint(data) + replay = anonymous_idempotency_replay( + makerspace, + idempotency_key_fingerprint, + payload_fingerprint, + ) + if replay is not None: + return Response( + RequestSubmitResponseSerializer(replay).data, + status=status.HTTP_201_CREATED, + ) + product_ids = [item["product_id"] for item in data["items"]] products = _requestable_products(product_ids, makerspace) if len(products) != len(product_ids): @@ -81,6 +156,27 @@ def post(self, request, makerspace_slug, *args, **kwargs): {"items": "One or more products are unavailable for request."} ) + if anonymous_submission: + requester_principal = get_or_create_anonymous_requester(makerspace) + contact_snapshot = RequesterSnapshot( + username="", + name=data["contact_name"].strip(), + email=data["contact_email"], + phone=data.get("contact_phone", ""), + contact_verified=False, + ) + audit_actor = None + else: + requester_principal = request.user + contact_snapshot = RequesterSnapshot( + username=request.user.username, + name=request.user.display_name, + email=request.user.email, + phone=request.user.phone, + contact_verified=True, + ) + audit_actor = request.user + hardware_request = workflow.submit_request( makerspace, [ @@ -91,7 +187,11 @@ def post(self, request, makerspace_slug, *args, **kwargs): for item in data["items"] ], data["requested_for"], - requester=request.user, + requester_principal=requester_principal, + contact_snapshot=contact_snapshot, + audit_actor=audit_actor, + idempotency_key_fingerprint=idempotency_key_fingerprint, + payload_fingerprint=payload_fingerprint, ) return Response( RequestSubmitResponseSerializer(hardware_request).data, @@ -99,6 +199,39 @@ def post(self, request, makerspace_slug, *args, **kwargs): ) +def _honeypot_response(): + decoy = SimpleNamespace( + public_token=uuid.uuid4(), + status=HardwareRequest.Status.PENDING_APPROVAL, + ) + return Response( + RequestSubmitResponseSerializer(decoy).data, + status=status.HTTP_201_CREATED, + ) + + +def _enforce_throttles(request, view, throttle_types): + waits = [] + for throttle_type in throttle_types: + throttle = throttle_type() + if not throttle.allow_request(request, view): + waits.append(throttle.wait()) + if waits: + durations = [wait for wait in waits if wait is not None] + raise Throttled(wait=max(durations) if durations else None) + + +def _anonymous_payload_fingerprint(data): + canonical = { + "contact_email": data["contact_email"], + "contact_name": data["contact_name"].strip(), + "contact_phone": data.get("contact_phone", ""), + "items": sorted(data["items"], key=lambda item: item["product_id"]), + "requested_for": data["requested_for"], + } + return fingerprint(json.dumps(canonical, sort_keys=True, separators=(",", ":"))) + + class RequestStatusView(generics.RetrieveAPIView): permission_classes = [AllowAny] throttle_classes = [ClientTierRateThrottle] diff --git a/backend/apps/hardware_requests/request_workflow.py b/backend/apps/hardware_requests/request_workflow.py index 50084498..4f3e30e6 100644 --- a/backend/apps/hardware_requests/request_workflow.py +++ b/backend/apps/hardware_requests/request_workflow.py @@ -1,3 +1,6 @@ +from dataclasses import dataclass + +from django.conf import settings from django.db import transaction from django.utils import timezone @@ -5,33 +8,67 @@ from apps.hardware_requests import notifications from apps.hardware_requests.models import HardwareRequest, HardwareRequestItem from apps.hardware_requests.workflow_errors import ( + AnonymousRequestIdempotencyConflict, + AnonymousRequestOutstandingLimit, InvalidTransition, RequestValidationError, RequesterBlocked, ) from apps.hardware_requests.workflow_utils import locked_request from apps.inventory import availability +from apps.makerspaces.models import Makerspace from apps.notifications.emit import emit_notification +@dataclass(frozen=True) +class RequesterSnapshot: + """Identity captured on a request independently from its durable principal.""" + + username: str + name: str + email: str + phone: str + contact_verified: bool + + def submit_request( makerspace, items, requested_for="", *, - requester, + requester_principal, + contact_snapshot, + audit_actor, + idempotency_key_fingerprint="", + payload_fingerprint="", ): with transaction.atomic(): from apps.encryption.write_fence import assert_mapped_write_allowed assert_mapped_write_allowed(makerspace.id) + if not contact_snapshot.contact_verified: + # Serialize the tenant ceiling with creation. A count followed by INSERT + # without this lock lets concurrent requests all observe spare capacity. + makerspace = Makerspace.objects.select_for_update().get(pk=makerspace.pk) + existing = anonymous_idempotency_replay( + makerspace, + idempotency_key_fingerprint, + payload_fingerprint, + ) + if existing is not None: + return existing + _enforce_anonymous_outstanding_limit(makerspace) + request = HardwareRequest.objects.create( makerspace=makerspace, - requester=requester, - requester_username=requester.username, - requester_name=requester.display_name, - requester_contact_email=requester.email, - requester_contact_phone=requester.phone, + requester=requester_principal, + requester_username=contact_snapshot.username, + requester_name=contact_snapshot.name, + requester_contact_email=contact_snapshot.email, + requester_contact_phone=contact_snapshot.phone, + requester_contact_verified=contact_snapshot.contact_verified, + anonymous_idempotency_key_fingerprint=idempotency_key_fingerprint, + anonymous_payload_fingerprint=payload_fingerprint, status=HardwareRequest.Status.PENDING_APPROVAL, requested_for=requested_for, ) @@ -46,7 +83,7 @@ def submit_request( ] ) audit.record( - requester, + audit_actor, "request.submitted", makerspace=makerspace, target=request, @@ -62,6 +99,35 @@ def submit_request( return request +def anonymous_idempotency_replay(makerspace, key_fingerprint, payload_fingerprint): + if not key_fingerprint: + raise RequestValidationError("An Idempotency-Key header is required.") + existing = HardwareRequest.objects.filter( + makerspace=makerspace, + anonymous_idempotency_key_fingerprint=key_fingerprint, + ).first() + if existing is None: + return None + if existing.anonymous_payload_fingerprint != payload_fingerprint: + raise AnonymousRequestIdempotencyConflict( + "This idempotency key was already used for a different request." + ) + return existing + + +def _enforce_anonymous_outstanding_limit(makerspace): + limit = settings.ANONYMOUS_REQUEST_OUTSTANDING_LIMIT + outstanding = HardwareRequest.objects.filter( + makerspace=makerspace, + status=HardwareRequest.Status.PENDING_APPROVAL, + ).exclude(anonymous_idempotency_key_fingerprint="").count() + if outstanding >= limit: + raise AnonymousRequestOutstandingLimit( + "This makerspace is not accepting more anonymous requests until " + "pending requests are reviewed." + ) + + def accept_request(actor, request, accepted=None): with transaction.atomic(): locked = locked_request(request) diff --git a/backend/apps/hardware_requests/serializers.py b/backend/apps/hardware_requests/serializers.py index 471c127a..8bf22a41 100644 --- a/backend/apps/hardware_requests/serializers.py +++ b/backend/apps/hardware_requests/serializers.py @@ -6,20 +6,65 @@ class RequestItemInputSerializer(serializers.Serializer): product_id = serializers.IntegerField() - quantity = serializers.IntegerField(min_value=1) + quantity = serializers.IntegerField(min_value=1, max_value=99) class RequestSubmitSerializer(serializers.Serializer): + CONTACT_FIELDS = ("contact_name", "contact_email", "contact_phone") + website = serializers.CharField(required=False, allow_blank=True, write_only=True) + contact_name = serializers.CharField( + required=False, + allow_blank=True, + max_length=200, + help_text="Required for an account-less submission.", + ) + contact_email = serializers.EmailField( + required=False, + allow_blank=True, + max_length=254, + help_text="Required for an account-less submission; normalized to lowercase.", + ) + contact_phone = serializers.CharField( + required=False, + allow_blank=True, + max_length=32, + ) requested_for = serializers.CharField( required=False, allow_blank=True, default="", + max_length=500, ) - items = RequestItemInputSerializer(many=True, allow_empty=False) + items = serializers.ListField( + child=RequestItemInputSerializer(), + allow_empty=False, + max_length=20, + ) + + def to_internal_value(self, data): + if not self.context.get("anonymous_submission", False): + # Authenticated identity remains account-derived. Removing these fields + # before child validation makes them genuinely ignored, including an + # oversized spoof value that must not turn into a validation side channel. + data = data.copy() + for field_name in self.CONTACT_FIELDS: + data.pop(field_name, None) + return super().to_internal_value(data) + + def validate_contact_email(self, value): + return value.strip().lower() def validate(self, attrs): attrs["website"] = attrs.get("website", "") + if self.context.get("anonymous_submission", False): + errors = {} + if not attrs.get("contact_name", "").strip(): + errors["contact_name"] = "This field is required." + if not attrs.get("contact_email", "").strip(): + errors["contact_email"] = "This field is required." + if errors: + raise serializers.ValidationError(errors) product_ids = [item["product_id"] for item in attrs["items"]] if len(product_ids) != len(set(product_ids)): raise serializers.ValidationError( @@ -101,6 +146,7 @@ class AdminRequestSerializer(serializers.Serializer): requester_display = serializers.SerializerMethodField() requester_contact_email = serializers.EmailField(read_only=True) requester_contact_phone = serializers.CharField(read_only=True) + requester_contact_verified = serializers.BooleanField(read_only=True) status = serializers.CharField(read_only=True) requested_for = serializers.CharField(read_only=True) rejection_reason = serializers.CharField(read_only=True) diff --git a/backend/apps/hardware_requests/throttles.py b/backend/apps/hardware_requests/throttles.py new file mode 100644 index 00000000..8f935fbf --- /dev/null +++ b/backend/apps/hardware_requests/throttles.py @@ -0,0 +1,37 @@ +"""Independent abuse budgets for account-less hardware-request proposals.""" + +from rest_framework.throttling import SimpleRateThrottle + +from apps.accounts.audit_events import fingerprint + + +class _AnonymousIpThrottle(SimpleRateThrottle): + def get_cache_key(self, request, view): + ident = self.get_ident(request) + return None if not ident else self.cache_format % { + "scope": self.scope, + "ident": ident, + } + + +class AnonymousRequestIpBurstThrottle(_AnonymousIpThrottle): + scope = "anonymous_request_ip_burst" + + +class AnonymousRequestIpHourThrottle(_AnonymousIpThrottle): + scope = "anonymous_request_ip_hour" + + +class AnonymousRequestEmailThrottle(SimpleRateThrottle): + scope = "anonymous_request_email" + + def get_cache_key(self, request, view): + email = str(getattr(request, "anonymous_contact_email", "") or "").strip().lower() + if not email: + return None + # Cache keys are operational data at rest. The normalized email is never + # embedded directly; this follows the password-reset/phone throttle pattern. + return self.cache_format % { + "scope": self.scope, + "ident": fingerprint(email), + } diff --git a/backend/apps/hardware_requests/workflow.py b/backend/apps/hardware_requests/workflow.py index 90a78e3b..d1fac415 100644 --- a/backend/apps/hardware_requests/workflow.py +++ b/backend/apps/hardware_requests/workflow.py @@ -10,6 +10,8 @@ ) from apps.hardware_requests.return_workflow import return_items from apps.hardware_requests.workflow_errors import ( + AnonymousRequestIdempotencyConflict, + AnonymousRequestOutstandingLimit, BoxUnavailable, BoxValidationError, EvidenceNotUploaded, @@ -20,6 +22,8 @@ ) __all__ = [ + "AnonymousRequestIdempotencyConflict", + "AnonymousRequestOutstandingLimit", "BoxUnavailable", "BoxValidationError", "EvidenceNotUploaded", diff --git a/backend/apps/hardware_requests/workflow_errors.py b/backend/apps/hardware_requests/workflow_errors.py index 9f7d3950..f857292d 100644 --- a/backend/apps/hardware_requests/workflow_errors.py +++ b/backend/apps/hardware_requests/workflow_errors.py @@ -10,6 +10,14 @@ class RequestValidationError(Exception): pass +class AnonymousRequestIdempotencyConflict(Exception): + pass + + +class AnonymousRequestOutstandingLimit(Exception): + pass + + class ReturnValidationError(Exception): pass diff --git a/backend/apps/makerspaces/anonymous_requesters.py b/backend/apps/makerspaces/anonymous_requesters.py index 36fbe958..b41e99ce 100644 --- a/backend/apps/makerspaces/anonymous_requesters.py +++ b/backend/apps/makerspaces/anonymous_requesters.py @@ -20,7 +20,13 @@ def get_or_create_anonymous_requester(makerspace): with transaction.atomic(): locked_space = Makerspace.objects.select_for_update().get(pk=makerspace.pk) if locked_space.anonymous_requester_id is not None: - return User.objects.get(pk=locked_space.anonymous_requester_id) + existing = User.objects.get(pk=locked_space.anonymous_requester_id) + # The caller handed us its own instance and will keep using it. Without + # this the row is created against `locked_space` and the caller's copy + # still reports `anonymous_requester = None`, which reads as "this space + # has no principal" at the very moment it just acquired one. + makerspace.anonymous_requester = existing + return existing # Reuse the existing member_ allocation namespace. The relationship is # the marker; inventing an anonymous_* namespace would contradict the migration @@ -39,4 +45,6 @@ def get_or_create_anonymous_requester(makerspace): locked_space.anonymous_requester = user locked_space.save(update_fields=["anonymous_requester"]) + # Same reason as above: keep the caller's instance consistent with the row. + makerspace.anonymous_requester = user return user diff --git a/backend/apps/openapi.py b/backend/apps/openapi.py index 0c66f22a..53f5d95b 100644 --- a/backend/apps/openapi.py +++ b/backend/apps/openapi.py @@ -38,7 +38,7 @@ PUBLIC_REQUEST_SUBMIT_EXAMPLE = OpenApiExample( "Submit public equipment request", value={ - "requester_name": "Shaan Shoukath", + "contact_name": "Shaan Shoukath", "contact_email": "shaans@example.com", "contact_phone": "+919876543210", "requested_for": "Electronics workshop diagnostics", diff --git a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py index 1bc766e6..d754a9c4 100644 --- a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py +++ b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py @@ -58,7 +58,7 @@ 'inventory.Category': frozenset('created_at display_order icon id makerspace name slug updated_at'.split()), 'inventory.InventoryProduct': frozenset('available_quantity box category created_at damaged_quantity description id image_key is_archived is_public issued_quantity lost_quantity makerspace name needs_fix_quantity public_availability_mode public_self_checkout_enabled reserved_quantity show_public_count storage_location total_quantity tracking_mode updated_at'.split()), 'inventory.InventoryAsset': frozenset('asset_tag box created_at id makerspace notes product public_self_checkout_enabled serial_number status updated_at'.split()), - 'hardware_requests.HardwareRequest': frozenset('accepted_at accepted_by assigned_box closed_at closed_by created_at id issue_evidence issue_remark issued_at issued_by makerspace public_token rejection_reason requested_for requester requester_contact_email requester_contact_phone requester_name requester_username return_due_at return_reminder_sent_at status updated_at'.split()), + 'hardware_requests.HardwareRequest': frozenset('accepted_at accepted_by anonymous_idempotency_key_fingerprint anonymous_payload_fingerprint assigned_box closed_at closed_by created_at id issue_evidence issue_remark issued_at issued_by makerspace public_token rejection_reason requested_for requester requester_contact_email requester_contact_phone requester_contact_verified requester_name requester_username return_due_at return_reminder_sent_at status updated_at'.split()), 'hardware_requests.HardwareRequestItem': frozenset('accepted_quantity damaged_quantity id issued_quantity missing_quantity needs_fix_quantity product request requested_quantity returned_quantity'.split()), 'hardware_requests.ReturnEvent': frozenset('actor box created_at evidence id makerspace remark request'.split()), 'hardware_requests.RequesterAccountability': frozenset('created_at created_by description evidence_photo id issue_type makerspace quantity request request_item requester'.split()), diff --git a/backend/config/settings.py b/backend/config/settings.py index b45510d1..d3110584 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -102,6 +102,20 @@ def normalize_platform_domain_suffix(raw): # by the operator's SMS vendor, so this is a cost ceiling rather than a fair-use quota. # Blank disables the cap entirely. OTP_SMS_DAILY_CAP = env.int("OTP_SMS_DAILY_CAP", default=200) +ANONYMOUS_REQUEST_OUTSTANDING_LIMIT = env.int( + "ANONYMOUS_REQUEST_OUTSTANDING_LIMIT", + default=50, +) +ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH = env.int( + "ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH", + default=128, +) +if ANONYMOUS_REQUEST_OUTSTANDING_LIMIT < 1: + raise ImproperlyConfigured("ANONYMOUS_REQUEST_OUTSTANDING_LIMIT must be positive.") +if ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH < 1: + raise ImproperlyConfigured( + "ANONYMOUS_REQUEST_IDEMPOTENCY_KEY_MAX_LENGTH must be positive." + ) STORAGE_PRESIGN_METHOD = env("STORAGE_PRESIGN_METHOD", default="post") CRON_SECRET = env("CRON_SECRET", default="") ADMIN_SITE_NAME = env("ADMIN_SITE_NAME", default="Space Works") @@ -764,6 +778,18 @@ def cache_config(cache_url): "THROTTLE_PUBLIC_REQUEST_SUBMIT", default="10/min", ), + "anonymous_request_ip_burst": env( + "THROTTLE_ANONYMOUS_REQUEST_IP_BURST", + default="2/min", + ), + "anonymous_request_ip_hour": env( + "THROTTLE_ANONYMOUS_REQUEST_IP_HOUR", + default="10/hour", + ), + "anonymous_request_email": env( + "THROTTLE_ANONYMOUS_REQUEST_EMAIL", + default="3/day", + ), "print_request_submit": env("THROTTLE_PRINT_REQUEST_SUBMIT", default="10/min"), "public_tool_checkout": env("THROTTLE_PUBLIC_TOOL_CHECKOUT", default="10/min"), "public_tool_return": env("THROTTLE_PUBLIC_TOOL_RETURN", default="10/min"), diff --git a/backend/tests/encryption/test_write_fence.py b/backend/tests/encryption/test_write_fence.py index 0e163d31..31f0337a 100644 --- a/backend/tests/encryption/test_write_fence.py +++ b/backend/tests/encryption/test_write_fence.py @@ -213,7 +213,17 @@ def test_mapped_service_paths_fail_with_the_typed_503_exception(monkeypatch): ) with pytest.raises(PiiWriteFenced): request_workflow.submit_request( - space, [], requester=actor + space, + [], + requester_principal=actor, + contact_snapshot=request_workflow.RequesterSnapshot( + username=actor.username, + name=actor.display_name, + email=actor.email, + phone=actor.phone, + contact_verified=True, + ), + audit_actor=actor, ) with pytest.raises(PiiWriteFenced): service_workflow.submit( diff --git a/backend/tests/test_anonymous_request_submit_b2.py b/backend/tests/test_anonymous_request_submit_b2.py new file mode 100644 index 00000000..a38edafc --- /dev/null +++ b/backend/tests/test_anonymous_request_submit_b2.py @@ -0,0 +1,262 @@ +"""B2-B: account-less request submission and its release-blocking abuse limits.""" + +import pytest +from django.core.cache import cache +from django.urls import reverse +from rest_framework.test import APIClient, APIRequestFactory + +from apps.accounts.audit_events import fingerprint +from apps.accounts.models import User +from apps.audit.models import AuditLog +from apps.hardware_requests.models import HardwareRequest +from apps.hardware_requests.throttles import AnonymousRequestEmailThrottle +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_profiles import RECOMMENDED, profile_modules + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _clear_throttle_cache(): + cache.clear() + yield + cache.clear() + + +def _space(slug, *, anonymous=True): + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=profile_modules(RECOMMENDED), + enabled_features=["inventory.self_checkout"], + anonymous_requests_enabled=anonymous, + ) + + +def _product(space, name="Logic analyzer"): + return InventoryProduct.objects.create( + makerspace=space, + name=name, + total_quantity=100, + available_quantity=100, + is_public=True, + ) + + +def _payload(product, *, email="Ada@Example.Test"): + return { + "contact_name": "Ada Lovelace", + "contact_email": email, + "contact_phone": "+44 (0)20 1234 5678", + "requested_for": "Bench diagnostics", + "items": [{"product_id": product.pk, "quantity": 1}], + } + + +def _submit(space, payload, key, *, ip="198.51.100.10", client=None): + return (client or APIClient()).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + payload, + format="json", + HTTP_IDEMPOTENCY_KEY=key, + REMOTE_ADDR=ip, + ) + + +def test_anonymous_submit_requires_opt_in_and_succeeds_when_enabled(): + disabled = _space("anonymous-disabled", anonymous=False) + disabled_response = _submit(disabled, _payload(_product(disabled)), "disabled") + + assert disabled_response.status_code == 401 + assert str(disabled_response.data["detail"]) == "Authentication credentials were not provided." + assert not HardwareRequest.objects.filter(makerspace=disabled).exists() + + enabled = _space("anonymous-enabled") + enabled_response = _submit(enabled, _payload(_product(enabled)), "enabled") + + assert enabled_response.status_code == 201, enabled_response.data + row = HardwareRequest.objects.get(makerspace=enabled) + # The view resolves its own Makerspace instance, so the principal is created + # against that row; this local copy predates it. + enabled.refresh_from_db() + assert row.requester == enabled.anonymous_requester + assert row.requester_username == "" + assert row.requester_name == "Ada Lovelace" + assert row.requester_contact_email == "ada@example.test" + assert row.requester_contact_verified is False + + +def test_authenticated_submit_ignores_contact_spoofing_and_keeps_account_identity(): + space = _space("authenticated-submit", anonymous=False) + product = _product(space) + user = User.objects.create_user( + username="real-account", + display_name="Real Account", + email="REAL@EXAMPLE.TEST", + phone="trusted phone", + ) + client = APIClient() + client.force_authenticate(user) + payload = _payload(product) + payload.update( + { + "contact_name": "x" * 201, + "contact_email": "not-an-email", + "contact_phone": "x" * 33, + } + ) + + response = _submit(space, payload, "ignored-for-auth", client=client) + + assert response.status_code == 201, response.data + row = HardwareRequest.objects.get(makerspace=space) + assert row.requester == user + assert row.requester_username == user.username + assert row.requester_name == user.display_name + assert row.requester_contact_email == user.email + assert row.requester_contact_phone == user.phone + assert row.requester_contact_verified is True + assert AuditLog.objects.get(action="request.submitted").actor == user + + +def test_anonymous_per_ip_burst_throttle_fires(): + space = _space("anonymous-ip-burst") + product = _product(space) + + responses = [ + _submit( + space, + _payload(product, email=f"person{index}@example.test"), + f"ip-burst-{index}", + ) + for index in range(3) + ] + + assert [response.status_code for response in responses] == [201, 201, 429] + + +def test_anonymous_per_ip_hour_throttle_fires(monkeypatch): + from apps.hardware_requests.throttles import ( + AnonymousRequestIpBurstThrottle, + AnonymousRequestIpHourThrottle, + ) + + monkeypatch.setattr(AnonymousRequestIpBurstThrottle, "rate", "100/min", raising=False) + monkeypatch.setattr(AnonymousRequestIpHourThrottle, "rate", "2/hour", raising=False) + space = _space("anonymous-ip-hour") + product = _product(space) + + responses = [ + _submit( + space, + _payload(product, email=f"hour{index}@example.test"), + f"ip-hour-{index}", + ) + for index in range(3) + ] + + assert [response.status_code for response in responses] == [201, 201, 429] + + +def test_anonymous_per_email_throttle_uses_only_a_fingerprint(): + space = _space("anonymous-email-limit") + product = _product(space) + email = "Target@Example.Test" + responses = [ + _submit( + space, + _payload(product, email=email), + f"email-{index}", + ip=f"198.51.100.{index + 1}", + ) + for index in range(4) + ] + + assert [response.status_code for response in responses] == [201, 201, 201, 429] + + request = APIRequestFactory().post("/", {}, format="json") + request.anonymous_contact_email = email.lower() + key = AnonymousRequestEmailThrottle().get_cache_key(request, object()) + assert email.lower() not in key + assert fingerprint(email) in key + + +def test_anonymous_audit_is_unattributed_and_principal_is_not_snapshotted(): + space = _space("anonymous-attribution") + response = _submit(space, _payload(_product(space)), "attribution") + + assert response.status_code == 201, response.data + row = HardwareRequest.objects.get(makerspace=space) + space.refresh_from_db() + assert row.requester == space.anonymous_requester + assert row.requester_username == "" + assert space.anonymous_requester.username not in { + row.requester_username, + row.requester_name, + row.requester_contact_email, + row.requester_contact_phone, + } + assert AuditLog.objects.get(action="request.submitted", target_id=str(row.pk)).actor is None + + +def test_unverified_anonymous_contact_never_receives_lifecycle_mail(monkeypatch): + from apps.hardware_requests import notifications + + space = _space("anonymous-no-requester-mail") + response = _submit(space, _payload(_product(space)), "no-requester-mail") + assert response.status_code == 201, response.data + row = HardwareRequest.objects.get(makerspace=space) + monkeypatch.setattr(notifications, "staff_emails_for_feature", lambda *args, **kwargs: []) + + def unexpected_requester_render(*args, **kwargs): + raise AssertionError("unverified requester mail must not be rendered") + + monkeypatch.setattr(notifications, "render_email", unexpected_requester_render) + assert notifications._email_deliveries(row, "request_received", "submitted") == () + + +def test_anonymous_idempotent_double_submit_creates_one_request_and_one_audit(): + space = _space("anonymous-idempotent") + payload = _payload(_product(space)) + + first = _submit(space, payload, "same-retry-key") + second = _submit(space, payload, "same-retry-key") + + assert first.status_code == second.status_code == 201 + assert first.data == second.data + assert HardwareRequest.objects.filter(makerspace=space).count() == 1 + assert AuditLog.objects.filter(action="request.submitted", makerspace=space).count() == 1 + + +def test_reused_idempotency_key_with_different_payload_is_a_typed_conflict(): + space = _space("anonymous-idempotency-conflict") + product = _product(space) + first = _submit(space, _payload(product), "reused-key") + changed = _payload(product) + changed["requested_for"] = "A different purpose" + second = _submit(space, changed, "reused-key") + + assert first.status_code == 201, first.data + assert second.status_code == 409 + assert second.data["code"] == "anonymous_request_idempotency_conflict" + assert HardwareRequest.objects.filter(makerspace=space).count() == 1 + + +def test_outstanding_anonymous_ceiling_returns_typed_error(settings): + settings.ANONYMOUS_REQUEST_OUTSTANDING_LIMIT = 1 + space = _space("anonymous-capacity") + product = _product(space) + + first = _submit(space, _payload(product), "capacity-first") + second = _submit( + space, + _payload(product, email="grace@example.test"), + "capacity-second", + ) + + assert first.status_code == 201, first.data + assert second.status_code == 429 + assert second.data["code"] == "anonymous_request_outstanding_limit" + assert HardwareRequest.objects.filter(makerspace=space).count() == 1 diff --git a/backend/tests/test_anonymous_request_submit_b2_limits.py b/backend/tests/test_anonymous_request_submit_b2_limits.py new file mode 100644 index 00000000..3745ccab --- /dev/null +++ b/backend/tests/test_anonymous_request_submit_b2_limits.py @@ -0,0 +1,80 @@ +"""B2-B boundary caps that do not need request-workflow database setup.""" + +import pytest +from django.conf import settings as django_settings + +from apps.encryption.registry import field_for +from apps.hardware_requests.models import HardwareRequest +from apps.hardware_requests.serializers import RequestSubmitSerializer + + +def _payload(): + return { + "contact_name": "Ada Lovelace", + "contact_email": "Ada@Example.Test", + "contact_phone": "+44 (0)20 1234 5678", + "requested_for": "Bench diagnostics", + "items": [{"product_id": 1, "quantity": 1}], + } + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("contact_name", "x" * 201), + ("contact_email", f"{'a' * 243}@example.com"), + ("contact_phone", "x" * 33), + ("requested_for", "x" * 501), + ("items", [{"product_id": index, "quantity": 1} for index in range(21)]), + ("items", [{"product_id": 1, "quantity": 100}]), + ], +) +def test_anonymous_serializer_rejects_every_hard_cap(field, value): + payload = _payload() + payload[field] = value + serializer = RequestSubmitSerializer( + data=payload, + context={"anonymous_submission": True}, + ) + + assert not serializer.is_valid() + assert field in serializer.errors + + +@pytest.mark.parametrize("missing_field", ["contact_name", "contact_email"]) +def test_anonymous_serializer_requires_name_and_email(missing_field): + payload = _payload() + payload.pop(missing_field) + serializer = RequestSubmitSerializer( + data=payload, + context={"anonymous_submission": True}, + ) + + assert not serializer.is_valid() + assert missing_field in serializer.errors + + +def test_authenticated_contact_fields_are_ignored_before_validation(): + payload = _payload() + payload.update( + { + "contact_name": "x" * 201, + "contact_email": "not-an-email", + "contact_phone": "x" * 33, + } + ) + serializer = RequestSubmitSerializer( + data=payload, + context={"anonymous_submission": False}, + ) + + assert serializer.is_valid(), serializer.errors + assert not set(RequestSubmitSerializer.CONTACT_FIELDS) & serializer.validated_data.keys() + + +def test_default_anonymous_abuse_rates_and_encryption_limit_are_configured(): + rates = django_settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] + assert rates["anonymous_request_ip_burst"] == "2/min" + assert rates["anonymous_request_ip_hour"] == "10/hour" + assert rates["anonymous_request_email"] == "3/day" + assert field_for(HardwareRequest, "requester_name").max_length == 200 From 728fcbf6cb2452399c99b4ae2bbb989a7d87db8b Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Tue, 1 Sep 2026 19:28:48 +0530 Subject: [PATCH 6/7] feat: outbound-only Telegram, account-less requests, staff dock, guard fixes Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 49 +++- CLAUDE.md | 49 +++- README.md | 26 +- backend/apps/accounts/claim_pre_auth_guard.py | 6 + backend/apps/audit/anchors_object_storage.py | 8 +- .../hardware_requests/admin_request_review.py | 132 +++++++++ .../apps/hardware_requests/admin_requests.py | 104 +++---- .../apps/hardware_requests/notifications.py | 32 +-- .../apps/hardware_requests/public_views.py | 44 ++- backend/apps/hardware_requests/throttles.py | 12 + .../apps/integrations/dispatch_channels.py | 1 - .../apps/integrations/models_destinations.py | 16 +- backend/apps/integrations/notify.py | 8 - backend/apps/integrations/telegram.py | 21 +- backend/apps/integrations/views.py | 93 +++---- .../apps/makerspaces/anonymous_requesters.py | 20 ++ .../management/commands/list_modules.py | 25 ++ .../management/commands/set_request_access.py | 79 ++++++ ...cile_anonymous_requests_with_membership.py | 43 +++ backend/apps/makerspaces/models_makerspace.py | 22 ++ backend/apps/makerspaces/module_registry.py | 23 +- backend/apps/makerspaces/request_access.py | 121 +++++++++ backend/apps/operations/accountability.py | 42 ++- .../apps/operations/org_report_identity.py | 5 + backend/apps/operations/reports_inventory.py | 5 + backend/apps/tenant_migration/gate_policy.py | 11 + .../tenant_migration/source_gate_guards.py | 2 + .../tenant_migration/tenant_dump_catalog.py | 2 +- .../hardware_requests/reject_action.html | 22 -- .../admin/hardware_requests/review.html | 76 ++++++ backend/tests/backup/test_archive_digests.py | 10 + .../test_archive_recipient_selection.py | 10 + .../tests/backup/test_compound_archive_e2.py | 10 +- .../test_producer_capability_gate_p1.py | 10 + backend/tests/encryption/test_rollout.py | 7 +- .../test_core_module_independence.py | 194 +++++++++++++ .../tests/makerspaces/test_request_access.py | 204 ++++++++++++++ .../test_anonymous_requester_isolation.py | 227 ++++++++++++++++ .../tests/test_anonymous_request_submit_b2.py | 23 ++ backend/tests/test_hardware_admin_actions.py | 134 ++++++--- .../tests/test_notification_destinations.py | 25 +- backend/tests/test_notification_dispatch.py | 16 +- backend/tests/test_notification_fanout.py | 16 +- backend/tests/test_telegram_integration.py | 59 ++-- docker-compose.dev.yml | 13 + docs/DEV-WORKFLOW.md | 14 +- docs/INVARIANTS.md | 71 +++++ docs/LANDING-PAGE-FEATURES.md | 2 +- docs/MODULES.md | 27 +- docs/PROJECT-STATUS.md | 3 +- frontend/openapi-schema.json | 55 +++- .../fonts/InstrumentSans-Variable.woff2 | Bin 30092 -> 0 bytes frontend/src/components/ThemeToggle.tsx | 15 +- frontend/src/components/icons.tsx | 92 +++++++ .../src/components/ui/CameraCapture.test.tsx | 162 +++++++++++ frontend/src/components/ui/CameraCapture.tsx | 256 ++++++++++++++++++ frontend/src/components/ui/DataTable.tsx | 3 +- frontend/src/components/ui/DetailDrawer.tsx | 45 ++- frontend/src/components/ui/ErrorBlock.tsx | 4 + frontend/src/components/ui/ErrorText.tsx | 4 + frontend/src/components/ui/Field.tsx | 28 ++ frontend/src/components/ui/IconButton.tsx | 44 +++ frontend/src/components/ui/Metric.tsx | 12 + frontend/src/components/ui/Modal.tsx | 45 ++- frontend/src/components/ui/QrScanner.tsx | 45 ++- .../src/components/ui/dialogFocus.test.ts | 122 +++++++++ frontend/src/components/ui/dialogFocus.ts | 103 +++++++ .../src/components/ui/dialogStack.test.tsx | 119 ++++++++ frontend/src/components/ui/index.ts | 6 + .../features/bookings/PublicBookingForm.tsx | 13 +- .../inventory/PublicEvidenceUpload.tsx | 65 +++-- .../inventory/PublicInventoryPage.tsx | 105 ++++--- .../features/staff/ApiClientCreateCard.tsx | 68 +++-- .../features/staff/ApiClientsPanel.test.tsx | 4 +- .../staff/ApiClientsTelegramSettings.tsx | 42 +-- .../src/features/staff/ChangePasswordGate.tsx | 1 + frontend/src/features/staff/EventsPanel.tsx | 4 +- .../src/features/staff/HandoverDialogs.tsx | 3 + frontend/src/features/staff/ImageUploader.tsx | 51 ++-- .../features/staff/IntegrationHealthPanel.tsx | 13 +- .../staff/MakerspaceEmailSettings.tsx | 40 +-- .../staff/MakerspacePaymentSettings.test.tsx | 2 +- .../staff/MakerspacePaymentSettings.tsx | 60 ++-- .../staff/MakerspaceSettingsPanel.tsx | 22 +- frontend/src/features/staff/MembersPanel.tsx | 8 +- .../staff/NotificationRecipientEvent.tsx | 3 + frontend/src/features/staff/PlatformApps.tsx | 16 +- .../src/features/staff/PlatformEmailPanel.tsx | 44 +-- .../staff/PlatformStripeConnectPanel.tsx | 39 +-- frontend/src/features/staff/StaffApp.tsx | 228 ++-------------- .../src/features/staff/StaffDock.test.tsx | 154 +++++++++++ frontend/src/features/staff/StaffDock.tsx | 239 ++++++++++++++++ .../src/features/staff/StaffDockPopover.tsx | 115 ++++++++ frontend/src/features/staff/StaffHeader.tsx | 41 ++- frontend/src/features/staff/StaffSidebar.tsx | 148 ---------- .../src/features/staff/StaffWorkspace.tsx | 45 ++- .../staff/handover/AssignBoxDialog.tsx | 53 ++++ .../features/staff/handover/IssueDialog.tsx | 104 +++++++ .../features/staff/handover/ReturnDialog.tsx | 144 ++++++++++ .../src/features/staff/handover/shared.tsx | 87 ++++++ frontend/src/features/staff/handover/types.ts | 34 +++ .../staff/panels/AccountabilityPanel.tsx | 16 +- .../src/features/staff/panels/AuditLog.tsx | 6 +- .../src/features/staff/panels/BulkImport.tsx | 20 +- .../src/features/staff/panels/Categories.tsx | 34 +-- .../features/staff/panels/EmailLogPanel.tsx | 12 +- .../features/staff/panels/EvidenceUpload.tsx | 67 ++++- .../src/features/staff/panels/Inventory.tsx | 10 +- frontend/src/features/staff/panels/Ledger.tsx | 12 +- .../src/features/staff/panels/LedgerParts.tsx | 4 +- .../staff/panels/OperationsReportsParts.tsx | 2 +- .../panels/OperationsReportsPayments.tsx | 8 +- .../panels/OrganizationAnalyticsPanel.tsx | 2 +- .../staff/panels/OrganizedEventsPanel.tsx | 12 +- .../staff/panels/ProcurementMoveForms.tsx | 8 +- .../staff/panels/ProcurementPanel.tsx | 28 +- .../staff/panels/ProcurementPanelRows.tsx | 6 +- .../src/features/staff/panels/QrTools.tsx | 40 ++- frontend/src/features/staff/panels/Queues.tsx | 9 +- .../staff/panels/QueuesAssignIssueModal.tsx | 4 +- .../staff/panels/QueuesModalShared.tsx | 17 +- .../features/staff/panels/QueuesModals.tsx | 4 +- .../staff/panels/QueuesReturnRequestModal.tsx | 5 +- .../staff/panels/StockTransferPanel.tsx | 26 +- .../staff/panels/StockTransferTable.tsx | 19 +- .../features/staff/panels/StocktakePanel.tsx | 2 +- .../src/features/staff/panels/UsersModals.tsx | 15 +- .../src/features/staff/panels/UsersTable.tsx | 2 +- .../features/staff/panels/WarrantyPanel.tsx | 16 +- .../staff/panels/machine/HandoverConsole.tsx | 6 +- .../panels/machine/MachineServiceConsole.tsx | 5 +- .../staff/panels/machine/OverviewTab.tsx | 2 +- .../panels/machine/PrinterServiceConsole.tsx | 5 +- .../machine/SharedConsumablesSection.tsx | 10 +- frontend/src/features/staff/staffNavIcons.tsx | 96 +++++++ frontend/src/features/staff/useDockAnchor.ts | 45 +++ .../src/features/staff/useStaffSession.ts | 207 ++++++++++++++ .../features/staff/useUnreadNotifications.ts | 16 ++ frontend/src/features/stats/StatsSections.tsx | 8 +- frontend/src/generated/api.ts | 4 + frontend/src/index.css | 26 +- frontend/src/styles/chrome.css | 35 +++ frontend/tailwind.config.ts | 25 +- scripts/module-selection.sh | 21 ++ setup.sh | 61 +++++ 145 files changed, 4950 insertions(+), 1357 deletions(-) create mode 100644 backend/apps/hardware_requests/admin_request_review.py create mode 100644 backend/apps/makerspaces/management/commands/set_request_access.py create mode 100644 backend/apps/makerspaces/migrations/0067_reconcile_anonymous_requests_with_membership.py create mode 100644 backend/apps/makerspaces/request_access.py delete mode 100644 backend/templates/admin/hardware_requests/reject_action.html create mode 100644 backend/templates/admin/hardware_requests/review.html create mode 100644 backend/tests/makerspaces/test_core_module_independence.py create mode 100644 backend/tests/makerspaces/test_request_access.py create mode 100644 backend/tests/operations/test_anonymous_requester_isolation.py delete mode 100644 frontend/public/fonts/InstrumentSans-Variable.woff2 create mode 100644 frontend/src/components/icons.tsx create mode 100644 frontend/src/components/ui/CameraCapture.test.tsx create mode 100644 frontend/src/components/ui/CameraCapture.tsx create mode 100644 frontend/src/components/ui/ErrorBlock.tsx create mode 100644 frontend/src/components/ui/ErrorText.tsx create mode 100644 frontend/src/components/ui/Field.tsx create mode 100644 frontend/src/components/ui/IconButton.tsx create mode 100644 frontend/src/components/ui/Metric.tsx create mode 100644 frontend/src/components/ui/dialogFocus.test.ts create mode 100644 frontend/src/components/ui/dialogStack.test.tsx create mode 100644 frontend/src/features/staff/HandoverDialogs.tsx create mode 100644 frontend/src/features/staff/StaffDock.test.tsx create mode 100644 frontend/src/features/staff/StaffDock.tsx create mode 100644 frontend/src/features/staff/StaffDockPopover.tsx delete mode 100644 frontend/src/features/staff/StaffSidebar.tsx create mode 100644 frontend/src/features/staff/handover/AssignBoxDialog.tsx create mode 100644 frontend/src/features/staff/handover/IssueDialog.tsx create mode 100644 frontend/src/features/staff/handover/ReturnDialog.tsx create mode 100644 frontend/src/features/staff/handover/shared.tsx create mode 100644 frontend/src/features/staff/handover/types.ts create mode 100644 frontend/src/features/staff/staffNavIcons.tsx create mode 100644 frontend/src/features/staff/useDockAnchor.ts create mode 100644 frontend/src/features/staff/useStaffSession.ts create mode 100644 frontend/src/features/staff/useUnreadNotifications.ts create mode 100644 frontend/src/styles/chrome.css diff --git a/AGENTS.md b/AGENTS.md index d332b8f8..9da7144b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,13 +39,15 @@ to action scope. ## Architecture: Concepts That Span Multiple Modules -UIs and the Telegram bot are thin clients over an API server composed of deep modules. Two architectural -rules are load-bearing and easy to violate if you only read one module: +UIs are thin clients over an API server composed of deep modules; Telegram is an outbound notification +channel only. Two architectural rules are load-bearing and easy to violate if you only read one module: -1. **The Request Workflow Module is the single source of truth for state transitions.** Telegram callbacks, - the web admin panel, and the guest-admin app must all route through the *same* workflow service — never - mutate `HardwareRequest.status` directly. The Telegram module in particular must call the workflow - module, not the database. This is what keeps web and bot behavior consistent and audited. +1. **The Request Workflow Module is the single source of truth for state transitions.** The web admin + panel, the guest-admin app and the `/control/` review page must all route through the *same* workflow + service — never mutate `HardwareRequest.status` directly. **Chat is not a decision surface**: the + Telegram accept/reject buttons and their callback route were removed, so there is no bot path into the + state machine to keep consistent any more. Re-introducing one means going through the workflow module, + never the database. 2. **The Inventory Availability Module owns all quantity math.** Reserve / issue / return / mark-lost all flow through it. No other module computes available/reserved/issued counts. The invariant "availability @@ -60,9 +62,10 @@ rules are load-bearing and easy to violate if you only read one module: Manager are both retired** — handover is a custom role, and `print_manager` survives only as the frozen legacy fallback in `_MEMBERSHIP_ROLE_ACTIONS` (migrations and enum archaeology under **Handover roles** in `docs/INVARIANTS.md`). Inventory Manager is membership-only and covers the full hardware lifecycle but - not printing, staff, or makerspace settings. Also verifies Telegram actors and blocks - restricted/suspended users. Interface: `can(actor, action, resource)`, - `scope_by_makerspace(actor, query)`, `assertTelegramActorCan(...)`. + not printing, staff, or makerspace settings. Also blocks restricted/suspended users. Interface: + `can(actor, action, resource)`, `scope_by_makerspace(actor, query)`. (It no longer verifies Telegram + actors — that went with the callback route. `assertTelegramActorCan` never existed in the code at all; + this line asserted it for months.) - **Request Workflow** — owns the state machine, emits audit logs, triggers Telegram alerts, coordinates inventory reservation/issue/return. - **Inventory Availability** — quantity math + asset status for QR-tracked tools. @@ -72,8 +75,9 @@ rules are load-bearing and easy to violate if you only read one module: - **Check-In API Client** — **RETIRED** (`73a480c`, Part M7). `apps/checkin/` no longer exists and there is no `CHECKIN_MODE` setting. Requester identity now comes from authenticated member accounts, so there is no external verify dependency left to fail safe on. -- **Telegram Integration** — sends per-makerspace group alerts and processes accept/reject callbacks - (delegating to Request Workflow). +- **Telegram Integration** — sends per-makerspace group alerts. **Outbound only.** The webhook route is + retained but accept-and-ignores every callback, because a deployment that already ran `setWebhook` would + otherwise have Telegram retry a 404 for hours; no chat message may carry an inline keyboard. ## Request State Machine @@ -198,6 +202,19 @@ starting a build.** These are the rules you must not violate without having read `./scripts/dev-local.sh test` with `spaceworks-db` (:5433), `spaceworks-redis` (:6379) and `spaceworks-minio` (:9200) up. **Never run two `pytest` procs against one DB**, and never run the full suite concurrently with `codex review` (it runs its own). +- **`tests/backup` and `tests/tenant_migration` need a pg client whose MAJOR equals the server's (16), + and the host may not have one.** `postgres_client.client_binary` resolves + `/usr/lib/postgresql/{major}/bin` (Debian/PGDG) or `/usr/pgsql-{major}/bin` (RHEL) and otherwise + fails closed — so on Arch, where neither path exists and `/usr/bin/pg_dump` is whatever `pacman` + ships, every one of those tests refuses with `PostgresClientUnavailable`. That is the environment, + **not a regression**. Run them in Docker instead, and note the DATABASE_URL override: the backend + container runs as the least-privilege `spaceworks_app` role, which has no CREATEDB, so pytest cannot + build a test database as itself. + + ```bash + ./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ + -T backend pytest tests/backup tests/tenant_migration -q + ``` - **Chain every new migration off the ACTUAL leaf** — `ls backend/apps//migrations/`, never the number a spec quotes. - **Commits sit local and unpushed on `dev`; pushing is the owner's call alone.** Ask @@ -212,8 +229,11 @@ starting a build.** These are the rules you must not violate without having read ```bash ./scripts/dev-docker.sh up -d --build # default: all in Docker, live reload -./scripts/dev-docker.sh exec backend pytest -./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host fallback: faster pytest +./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host: faster pytest, most of the suite + +# In Docker, pytest needs the DB OWNER: the backend runs as `spaceworks_app`, which has no CREATEDB. +./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ + -T backend pytest ``` Public inventory page: `http://localhost:5000/m/makerspace`. API: `http://localhost:8000/api` — Swagger UI @@ -243,7 +263,8 @@ Stack (in use): - **API documentation:** drf-spectacular / OpenAPI (snapshot `frontend/openapi-schema.json` + generated `frontend/src/generated/api.ts`; regenerate both when routes/models change — spectacular needs `--format openapi-json`). -- **Telegram:** request alerts, test alerts, authenticated webhook accept/reject callbacks. +- **Telegram:** request alerts and test alerts. Outbound only — the webhook acknowledges and discards + callbacks; decisions are made in the staff console or `/control/`. ## Current source map — in `docs/SOURCE-MAP.md` diff --git a/CLAUDE.md b/CLAUDE.md index d332b8f8..9da7144b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,13 +39,15 @@ to action scope. ## Architecture: Concepts That Span Multiple Modules -UIs and the Telegram bot are thin clients over an API server composed of deep modules. Two architectural -rules are load-bearing and easy to violate if you only read one module: +UIs are thin clients over an API server composed of deep modules; Telegram is an outbound notification +channel only. Two architectural rules are load-bearing and easy to violate if you only read one module: -1. **The Request Workflow Module is the single source of truth for state transitions.** Telegram callbacks, - the web admin panel, and the guest-admin app must all route through the *same* workflow service — never - mutate `HardwareRequest.status` directly. The Telegram module in particular must call the workflow - module, not the database. This is what keeps web and bot behavior consistent and audited. +1. **The Request Workflow Module is the single source of truth for state transitions.** The web admin + panel, the guest-admin app and the `/control/` review page must all route through the *same* workflow + service — never mutate `HardwareRequest.status` directly. **Chat is not a decision surface**: the + Telegram accept/reject buttons and their callback route were removed, so there is no bot path into the + state machine to keep consistent any more. Re-introducing one means going through the workflow module, + never the database. 2. **The Inventory Availability Module owns all quantity math.** Reserve / issue / return / mark-lost all flow through it. No other module computes available/reserved/issued counts. The invariant "availability @@ -60,9 +62,10 @@ rules are load-bearing and easy to violate if you only read one module: Manager are both retired** — handover is a custom role, and `print_manager` survives only as the frozen legacy fallback in `_MEMBERSHIP_ROLE_ACTIONS` (migrations and enum archaeology under **Handover roles** in `docs/INVARIANTS.md`). Inventory Manager is membership-only and covers the full hardware lifecycle but - not printing, staff, or makerspace settings. Also verifies Telegram actors and blocks - restricted/suspended users. Interface: `can(actor, action, resource)`, - `scope_by_makerspace(actor, query)`, `assertTelegramActorCan(...)`. + not printing, staff, or makerspace settings. Also blocks restricted/suspended users. Interface: + `can(actor, action, resource)`, `scope_by_makerspace(actor, query)`. (It no longer verifies Telegram + actors — that went with the callback route. `assertTelegramActorCan` never existed in the code at all; + this line asserted it for months.) - **Request Workflow** — owns the state machine, emits audit logs, triggers Telegram alerts, coordinates inventory reservation/issue/return. - **Inventory Availability** — quantity math + asset status for QR-tracked tools. @@ -72,8 +75,9 @@ rules are load-bearing and easy to violate if you only read one module: - **Check-In API Client** — **RETIRED** (`73a480c`, Part M7). `apps/checkin/` no longer exists and there is no `CHECKIN_MODE` setting. Requester identity now comes from authenticated member accounts, so there is no external verify dependency left to fail safe on. -- **Telegram Integration** — sends per-makerspace group alerts and processes accept/reject callbacks - (delegating to Request Workflow). +- **Telegram Integration** — sends per-makerspace group alerts. **Outbound only.** The webhook route is + retained but accept-and-ignores every callback, because a deployment that already ran `setWebhook` would + otherwise have Telegram retry a 404 for hours; no chat message may carry an inline keyboard. ## Request State Machine @@ -198,6 +202,19 @@ starting a build.** These are the rules you must not violate without having read `./scripts/dev-local.sh test` with `spaceworks-db` (:5433), `spaceworks-redis` (:6379) and `spaceworks-minio` (:9200) up. **Never run two `pytest` procs against one DB**, and never run the full suite concurrently with `codex review` (it runs its own). +- **`tests/backup` and `tests/tenant_migration` need a pg client whose MAJOR equals the server's (16), + and the host may not have one.** `postgres_client.client_binary` resolves + `/usr/lib/postgresql/{major}/bin` (Debian/PGDG) or `/usr/pgsql-{major}/bin` (RHEL) and otherwise + fails closed — so on Arch, where neither path exists and `/usr/bin/pg_dump` is whatever `pacman` + ships, every one of those tests refuses with `PostgresClientUnavailable`. That is the environment, + **not a regression**. Run them in Docker instead, and note the DATABASE_URL override: the backend + container runs as the least-privilege `spaceworks_app` role, which has no CREATEDB, so pytest cannot + build a test database as itself. + + ```bash + ./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ + -T backend pytest tests/backup tests/tenant_migration -q + ``` - **Chain every new migration off the ACTUAL leaf** — `ls backend/apps//migrations/`, never the number a spec quotes. - **Commits sit local and unpushed on `dev`; pushing is the owner's call alone.** Ask @@ -212,8 +229,11 @@ starting a build.** These are the rules you must not violate without having read ```bash ./scripts/dev-docker.sh up -d --build # default: all in Docker, live reload -./scripts/dev-docker.sh exec backend pytest -./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host fallback: faster pytest +./scripts/dev-local.sh infra && ./scripts/dev-local.sh test # host: faster pytest, most of the suite + +# In Docker, pytest needs the DB OWNER: the backend runs as `spaceworks_app`, which has no CREATEDB. +./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ + -T backend pytest ``` Public inventory page: `http://localhost:5000/m/makerspace`. API: `http://localhost:8000/api` — Swagger UI @@ -243,7 +263,8 @@ Stack (in use): - **API documentation:** drf-spectacular / OpenAPI (snapshot `frontend/openapi-schema.json` + generated `frontend/src/generated/api.ts`; regenerate both when routes/models change — spectacular needs `--format openapi-json`). -- **Telegram:** request alerts, test alerts, authenticated webhook accept/reject callbacks. +- **Telegram:** request alerts and test alerts. Outbound only — the webhook acknowledges and discards + callbacks; decisions are made in the staff console or `/control/`. ## Current source map — in `docs/SOURCE-MAP.md` diff --git a/README.md b/README.md index a62d493d..22385163 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ cannot be removed. **Default** means it is on when you install without choosing | | [`member_accounts`](docs/MODULES.md#member_accounts) | | | Member sign-up and member sign-in | | **Notifications** | [`notifications`](docs/MODULES.md#notifications) | | | The in-app inbox | | | [`email`](docs/MODULES.md#email) | | | Outbound email | -| | [`telegram`](docs/MODULES.md#telegram) | | | Telegram alerts and accept/reject buttons | +| | [`telegram`](docs/MODULES.md#telegram) | | | Telegram group alerts (outbound only) | | | [`slack`](docs/MODULES.md#slack) | | | Slack alerts | | | [`mattermost`](docs/MODULES.md#mattermost) | | | Mattermost alerts | | | [`discord`](docs/MODULES.md#discord) | | | Discord alerts | @@ -321,6 +321,26 @@ People still get named, two ways: It is enough to issue them a tool, register them for an event or run a machine job for them, and it keeps every handover attributable to a real person, which the hardware rules require. +#### Who may ask to borrow something + +Borrow requests are only ever a *proposal* — staff still accept them, and staff acceptance is what +reserves stock — so who may submit one is a separate switch from who may sign in. There are three +states, and you never set them both: + +| Member accounts | Account-less requests | Who may submit | +| --- | --- | --- | +| on | off | **Members** — an active member of that makerspace | +| off | off | **Account holders** — anyone signed in (the default without the module) | +| off | **on** | **Anyone** — no account at all | + +The last row is opt-in per makerspace, and turning **Member accounts** on turns it back off: enabling +membership means asking for membership, so a stranger must not still walk past it. Account-less +submissions ask for a name, email and phone, require an `Idempotency-Key`, and are rate-limited per IP +and per email address; every one of them is recorded against a single shared requester principal, which +is deliberately excluded from every per-person ranking so a hundred strangers never add up to one +fictional "top borrower". `setup.sh` asks this question during first-run setup, and it can be changed +later with `manage.py set_request_access`. + ### Choosing which ways in you offer **`/control/` → Platform login methods** switches the four credential kinds independently: password, @@ -354,8 +374,8 @@ Two things worth knowing before you set this up: - **Webhook URLs are write-only.** They are stored encrypted and never shown again, so an edit that only renames a room can leave the field blank. - **Telegram rooms share the makerspace's bot** — add the same bot to each group and paste each - group's chat ID. That is what keeps the accept/reject buttons working, since Telegram sends every - button press back to one address. + group's chat ID. Delivery is outbound only: chat is not a decision surface, so no alert carries + accept/reject buttons and decisions are made in the staff console or `/control/`. A makerspace that has added no rooms keeps using the single webhook under **Chat webhooks**, exactly as before. Nothing changes until you add your first room. diff --git a/backend/apps/accounts/claim_pre_auth_guard.py b/backend/apps/accounts/claim_pre_auth_guard.py index 9fb6ad52..a5482dad 100644 --- a/backend/apps/accounts/claim_pre_auth_guard.py +++ b/backend/apps/accounts/claim_pre_auth_guard.py @@ -47,6 +47,12 @@ "apps.events.throttles.CollaborativeRegistrationThrottle": { "_tier", "allow_request", "get_cache_key" }, + # The account-less borrow-request budgets. Both override `get_cache_key` only, to + # return None for an authenticated caller: they sit in `throttle_classes` beside the + # member throttle, and DRF applies every class on every request, so without the skip a + # makerspace behind one NAT would rate-limit its own signed-in members by egress IP. + "apps.hardware_requests.throttles.AnonymousRequestIpBurstThrottle": {"get_cache_key"}, + "apps.hardware_requests.throttles.AnonymousRequestIpHourThrottle": {"get_cache_key"}, "apps.machines.permissions.IsActiveRequester": {"has_permission"}, "apps.accounts.views_device.IsDeviceAccessToken": {"has_permission"}, "apps.makerspaces.throttles.MemberImagePresignThrottle": {"get_cache_key"}, diff --git a/backend/apps/audit/anchors_object_storage.py b/backend/apps/audit/anchors_object_storage.py index c76dc81c..59f96cf1 100644 --- a/backend/apps/audit/anchors_object_storage.py +++ b/backend/apps/audit/anchors_object_storage.py @@ -31,8 +31,14 @@ def __init__(self): self.retention_days = int( getattr(settings, "AUDIT_ATTESTATION_RETENTION_DAYS", 0) ) + # `or "COMPLIANCE"` because BLANK is not the same as absent here. Compose passes + # `AUDIT_ATTESTATION_S3_OBJECT_LOCK_MODE: ${...:-}`, which makes the variable + # present-but-empty, and django-environ only applies its default when a variable + # is ABSENT -- so the setting arrives as "" and the getattr default never fires. + # Every deployment that left the mode unset in .env therefore refused to anchor. + # COMPLIANCE is the stricter of the two modes, so defaulting to it fails safe. self.lock_mode = str( - getattr(settings, "AUDIT_ATTESTATION_S3_OBJECT_LOCK_MODE", "COMPLIANCE") + getattr(settings, "AUDIT_ATTESTATION_S3_OBJECT_LOCK_MODE", "") or "COMPLIANCE" ).upper() if not self.bucket or self.retention_days < 1: raise AnchorError( diff --git a/backend/apps/hardware_requests/admin_request_review.py b/backend/apps/hardware_requests/admin_request_review.py new file mode 100644 index 00000000..cdd77bf3 --- /dev/null +++ b/backend/apps/hardware_requests/admin_request_review.py @@ -0,0 +1,132 @@ +"""One request, one decision — the review surface that replaced bulk accept/reject. + +Accepting a borrow request RESERVES inventory and rejecting one closes a person's ask, and +neither is a judgement you can make about twenty rows from a checkbox column. The bulk +`accept_selected` / `reject_selected` actions are gone; a decision is made here, on a page +that shows who is asking, whether their contact was ever verified, and exactly what they +want, with a per-item accepted quantity you can lower before you commit stock. + +The mutations still go through `request_workflow`, so the state machine, the audit entry +and the notification fan-out are identical to every other surface — only the *entry point* +changed. See the module-level note in `apps.hardware_requests.workflow` for why nothing may +mutate `HardwareRequest.status` directly. +""" + +from django.contrib import messages +from django.http import Http404 +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import reverse + +from apps.hardware_requests.admin_workflow import WORKFLOW_EXCEPTIONS +from apps.hardware_requests.models import HardwareRequest +from apps.hardware_requests.request_workflow import accept_request, reject_request + + +class RequestReviewAdminMixin: + """Adds `/review/` to a HardwareRequest ModelAdmin.""" + + review_template = "admin/hardware_requests/review.html" + + def get_urls(self): + from django.urls import path + + return [ + path( + "/review/", + # `admin_view` applies the admin's own authentication and the + # never-cache headers. Without it this is an ordinary view that any + # logged-in user could reach, which for a state-changing POST is the + # whole ballgame. + self.admin_site.admin_view(self.review_view), + name="hardware_requests_hardwarerequest_review", + ), + *super().get_urls(), + ] + + def _review_object(self, request, request_id): + # `self.get_queryset(request)` and NOT `HardwareRequest.objects`: the superadmin + # queryset excludes hard-hidden makerspaces, and reaching around it here would + # make this URL the one place that ignores the control-plane hidden-tenant rule. + queryset = self.get_queryset(request).prefetch_related("items__product") + obj = queryset.filter(pk=request_id).first() + if obj is None: + raise Http404("No hardware request matches the given query.") + return obj + + def review_view(self, request, request_id): + obj = self._review_object(request, request_id) + if not self.has_change_permission(request, obj): + raise Http404("No hardware request matches the given query.") + + if request.method == "POST": + return self._apply_review(request, obj) + + return TemplateResponse( + request, + self.review_template, + { + **self.admin_site.each_context(request), + "title": f"Review hardware request #{obj.pk}", + "hardware_request": obj, + "items": list(obj.items.all()), + "opts": self.model._meta, + "is_pending": obj.status == HardwareRequest.Status.PENDING_APPROVAL, + }, + ) + + def _apply_review(self, request, obj): + changelist = reverse("admin:hardware_requests_hardwarerequest_changelist") + if "reject" in request.POST: + reason = request.POST.get("reason", "").strip() + if not reason: + self.message_user(request, "Rejection reason is required.", level=messages.ERROR) + return redirect(request.path) + try: + reject_request(request.user, obj, reason) + except WORKFLOW_EXCEPTIONS as exc: + self.message_user(request, f"{obj.pk}: {exc}", level=messages.ERROR) + return redirect(request.path) + self.message_user(request, f"Rejected hardware request #{obj.pk}.", level=messages.SUCCESS) + return redirect(changelist) + + try: + accepted = self._accepted_quantities(request, obj) + except ValueError as exc: + self.message_user(request, str(exc), level=messages.ERROR) + return redirect(request.path) + + try: + accept_request(request.user, obj, accepted=accepted) + except WORKFLOW_EXCEPTIONS as exc: + self.message_user(request, f"{obj.pk}: {exc}", level=messages.ERROR) + return redirect(request.path) + self.message_user(request, f"Accepted hardware request #{obj.pk}.", level=messages.SUCCESS) + return redirect(changelist) + + @staticmethod + def _accepted_quantities(request, obj): + """Parse the per-item inputs into the map `accept_request` expects. + + Returns `None` when the form carried no quantities at all, which `accept_request` + reads as "accept everything as requested" — the same default the API uses for an + omitted `accepted_quantities`. Anything present is parsed as an int here rather + than handed to the workflow as a string, so a malformed field is an error on this + page instead of a 500 inside the service. + """ + quantities = {} + for item in obj.items.all(): + raw = request.POST.get(f"accepted_quantity_{item.pk}") + if raw is None or raw == "": + continue + try: + value = int(raw) + except (TypeError, ValueError): + raise ValueError(f"Accepted quantity for item {item.pk} must be a whole number.") + if value < 0 or value > item.requested_quantity: + raise ValueError( + f"Accepted quantity for item {item.pk} must be between 0 and " + f"{item.requested_quantity}." + ) + quantities[item.pk] = value + return quantities or None diff --git a/backend/apps/hardware_requests/admin_requests.py b/backend/apps/hardware_requests/admin_requests.py index a571f3bf..44007fd8 100644 --- a/backend/apps/hardware_requests/admin_requests.py +++ b/backend/apps/hardware_requests/admin_requests.py @@ -3,6 +3,8 @@ from django.contrib import messages from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.template.response import TemplateResponse +from django.urls import reverse +from django.utils.html import format_html from unfold.admin import ModelAdmin, TabularInline from apps.hardware_requests.admin_workflow import WORKFLOW_EXCEPTIONS @@ -10,8 +12,8 @@ assign_box, set_return_due as workflow_set_return_due, ) +from apps.hardware_requests.admin_request_review import RequestReviewAdminMixin from apps.hardware_requests.models import HardwareRequest, HardwareRequestItem -from apps.hardware_requests.request_workflow import accept_request, reject_request from config.admin_access import SuperuserOnlyModelAdmin from apps.encryption.admin_search import ScopedPiiAdminSearchMixin @@ -48,10 +50,14 @@ def has_add_permission(self, request, obj=None): @admin.register(HardwareRequest) -class HardwareRequestAdmin(ScopedPiiAdminSearchMixin, SuperuserOnlyModelAdmin, ModelAdmin): +class HardwareRequestAdmin( + RequestReviewAdminMixin, ScopedPiiAdminSearchMixin, SuperuserOnlyModelAdmin, ModelAdmin +): + # Accepting reserves stock and rejecting closes a person's ask, so neither is a + # checkbox-column decision any more: both moved to the one-by-one review page + # (`admin_request_review`). What survives here is genuinely bulk-shaped work on + # requests whose decision has ALREADY been made. actions = [ - "accept_selected", - "reject_selected", "assign_box_selected", "set_return_due", ] @@ -62,8 +68,11 @@ class HardwareRequestAdmin(ScopedPiiAdminSearchMixin, SuperuserOnlyModelAdmin, M "requester_identity", "return_due_at", "created_at", + "review_link", ) - list_filter = ("status", "makerspace") + # `requester_contact_verified` is filterable so the unverified submissions -- which + # are exactly the account-less ones -- can be pulled up as a group before handover. + list_filter = ("status", "makerspace", "requester_contact_verified") search_fields = ( "requested_for", "rejection_reason", @@ -73,6 +82,7 @@ class HardwareRequestAdmin(ScopedPiiAdminSearchMixin, SuperuserOnlyModelAdmin, M "makerspace", "requester", "requester_username", + "requester_contact_verified", "status", "requested_for", "rejection_reason", @@ -96,80 +106,32 @@ class HardwareRequestAdmin(ScopedPiiAdminSearchMixin, SuperuserOnlyModelAdmin, M @admin.display(description="Requester") def requester_identity(self, obj): - return obj.requester_name or obj.requester_contact_email or "-" + label = obj.requester_name or obj.requester_contact_email or "-" + if obj.requester_contact_verified: + return label + # An account-less submitter typed this address themselves and nothing has ever + # proved it is theirs -- staff acceptance does not prove it either. Marking it + # here is what stops the queue from reading like a list of known people. + return f"{label} (unverified contact)" + + @admin.display(description="Review") + def review_link(self, obj): + if obj.status != HardwareRequest.Status.PENDING_APPROVAL: + return "-" + url = reverse( + "admin:hardware_requests_hardwarerequest_review", args=[obj.pk] + ) + return format_html('Review', url) # Requests are created by the public submit flow and mutated only through the - # workflow services (the actions below). Direct add hits required readonly fields - # and direct delete bypasses reservation/audit/notification cleanup. + # workflow services. Direct add hits required readonly fields and direct delete + # bypasses reservation/audit/notification cleanup. def has_add_permission(self, request): return False def has_delete_permission(self, request, obj=None): return False - @admin.action(description="Accept selected requests") - def accept_selected(self, request, queryset): - success_count = 0 - for hardware_request in queryset: - try: - accept_request(request.user, hardware_request) - except WORKFLOW_EXCEPTIONS as exc: - self.message_user( - request, - f"{hardware_request.pk}: {exc}", - level=messages.ERROR, - ) - else: - success_count += 1 - - if success_count: - self.message_user( - request, - f"Accepted {success_count} hardware request(s).", - level=messages.SUCCESS, - ) - - @admin.action(description="Reject selected requests (with reason)") - def reject_selected(self, request, queryset): - if "apply" not in request.POST: - return self._intermediate_action_response( - request, - queryset, - "admin/hardware_requests/reject_action.html", - "Reject selected hardware requests", - "reject_selected", - ) - - reason = request.POST.get("reason", "").strip() - if not reason: - self.message_user( - request, - "Rejection reason is required.", - level=messages.ERROR, - ) - return None - - success_count = 0 - for hardware_request in queryset: - try: - reject_request(request.user, hardware_request, reason) - except WORKFLOW_EXCEPTIONS as exc: - self.message_user( - request, - f"{hardware_request.pk}: {exc}", - level=messages.ERROR, - ) - else: - success_count += 1 - - if success_count: - self.message_user( - request, - f"Rejected {success_count} hardware request(s).", - level=messages.SUCCESS, - ) - return None - @admin.action(description="Assign box to selected requests") def assign_box_selected(self, request, queryset): if "apply" not in request.POST: diff --git a/backend/apps/hardware_requests/notifications.py b/backend/apps/hardware_requests/notifications.py index 5aa7d483..85d3ee5a 100644 --- a/backend/apps/hardware_requests/notifications.py +++ b/backend/apps/hardware_requests/notifications.py @@ -16,7 +16,6 @@ def notify_request_submitted(request): requester_key="request_received", staff_event="submitted", text_builder=_build_submitted_request_message, - reply_markup_builder=_submitted_reply_markup, ) @@ -84,7 +83,6 @@ def _notify( requester_key, staff_event, text_builder, - reply_markup_builder=None, sync=False, ): logger.info( @@ -107,12 +105,7 @@ def build(): .get(pk=request_id) ) emails = _email_deliveries(row, requester_key, staff_event) - markup = reply_markup_builder(row) if reply_markup_builder else None - return LifecyclePayload( - text=text_builder(row), - emails=emails, - telegram_reply_markup=markup, - ) + return LifecyclePayload(text=text_builder(row), emails=emails) return notify_lifecycle( request.makerspace, @@ -180,20 +173,6 @@ def render_email(request, key): ) -def _submitted_reply_markup(request): - return { - "inline_keyboard": [ - [ - {"text": "Accept", "callback_data": f"accept:{request.pk}"}, - { - "text": "Reject", - "callback_data": f"reject:{request.pk}:Rejected from Telegram.", - }, - ] - ] - } - - def _build_submitted_request_message(request): lines = [ f"New hardware request #{request.pk}", @@ -203,6 +182,11 @@ def _build_submitted_request_message(request): lines.append(f"Email: {request.requester_contact_email}") if request.requester_contact_phone: lines.append(f"Phone: {request.requester_contact_phone}") + if not request.requester_contact_verified: + # The same warning the staff console and /control/ carry. Telegram is where a + # request is FIRST seen, so omitting it here would mean the one surface that + # reaches staff instantly is the one that implies the contact is trustworthy. + lines.append("Contact NOT verified - confirm identity before handing over.") if request.requested_for: lines.append(f"Requested for: {_clamp(request.requested_for, 300)}") items = list(request.items.all()) @@ -215,6 +199,10 @@ def _build_submitted_request_message(request): lines.append(f"- ...and {len(items) - len(shown)} more") else: lines.append("Items: None") + # Telegram is a NOTIFICATION channel, not a decision surface: the accept/reject + # buttons that used to ride on this message are gone, so the alert has to say where + # the decision is actually made or it becomes a dead end. + lines.append("Review and decide in the staff console.") return _clamp("\n".join(lines), 4000) diff --git a/backend/apps/hardware_requests/public_views.py b/backend/apps/hardware_requests/public_views.py index 69a782e5..a69ae279 100644 --- a/backend/apps/hardware_requests/public_views.py +++ b/backend/apps/hardware_requests/public_views.py @@ -37,6 +37,7 @@ from apps.makerspaces.anonymous_requesters import get_or_create_anonymous_requester from apps.makerspaces.lookup import get_public_makerspace from apps.makerspaces.platform import module_enabled +from apps.makerspaces.request_access import anonymous_requests_allowed from apps.makerspaces.servability import servable_queryset from apps.presence.guard import require_active_account, require_active_member_presence from apps.openapi import ( @@ -48,15 +49,26 @@ class RequestSubmitView(APIView): permission_classes = [AllowAny] - # Anonymous throttles are selected inside post(). The authenticated throttle stays - # in APIView.initial() through check_throttles(), preserving the original ordering. - throttle_classes = [] + # Every IP/principal budget is declared here so DRF applies it in APIView.initial(), + # BEFORE the handler runs. `check_throttles` is deliberately NOT overridden: it is a + # pre-auth lifecycle hook, `apps.accounts.claim_route_guard` refuses any route that + # replaces one, and overriding it here had already cost the endpoint its floor -- + # anonymous throttles were selected inside post(), which is *after* the + # `anonymous_requests_allowed` refusal, so every makerspace that had not opted in + # served an UNTHROTTLED 401 that still paid for a makerspace lookup. + # + # Each class self-selects by auth state (see `_AnonymousIpThrottle`), so listing them + # together does not double-charge anyone. Consequence worth knowing: the honeypot no + # longer precedes the IP budget, so a bot loud enough to exhaust it sees 429 instead + # of the honeypot's fake success. Being rate-limited before being fingerprinted is + # the safer of the two, and the honeypot still absorbs every bot under the limit. + throttle_classes = [ + MemberPrincipalRateThrottle, + AnonymousRequestIpBurstThrottle, + AnonymousRequestIpHourThrottle, + ] throttle_scope = "public_request_submit" - def check_throttles(self, request): - if request.user.is_authenticated: - _enforce_throttles(request, self, (MemberPrincipalRateThrottle,)) - @extend_schema( tags=["Public requests"], summary="Submit public borrow request", @@ -82,21 +94,23 @@ def post(self, request, makerspace_slug, *args, **kwargs): makerspace = get_public_makerspace(makerspace_slug) anonymous_submission = not request.user.is_authenticated if anonymous_submission: - if not makerspace.anonymous_requests_enabled: + if not anonymous_requests_allowed(makerspace): # Raising DRF's own exception preserves the previous IsAuthenticated # response body as well as its 401 status for every non-opted-in space. + # + # `anonymous_requests_allowed` re-derives the answer rather than reading + # the column, so a row that somehow carries BOTH the flag and the + # `membership` module -- raw SQL, an old backup, a restore that predates + # the model rule -- still fails closed here instead of admitting a + # stranger past the membership requirement. raise NotAuthenticated() # Resolving the opt-in flag is unavoidable because disabled spaces must - # retain their 401. From here the raw honeypot precedes module checks, - # throttling, serializer work, product queries and principal creation. + # retain their 401. The IP budgets were already charged in initial(); from + # here the raw honeypot precedes module checks, serializer work, product + # queries and principal creation. if _honeypot_filled(request.data): return _honeypot_response() _require_module(makerspace, "request_workflow") - _enforce_throttles( - request, - self, - (AnonymousRequestIpBurstThrottle, AnonymousRequestIpHourThrottle), - ) else: _require_module(makerspace, "request_workflow") if module_enabled(makerspace, "membership"): diff --git a/backend/apps/hardware_requests/throttles.py b/backend/apps/hardware_requests/throttles.py index 8f935fbf..995b6101 100644 --- a/backend/apps/hardware_requests/throttles.py +++ b/backend/apps/hardware_requests/throttles.py @@ -6,7 +6,19 @@ class _AnonymousIpThrottle(SimpleRateThrottle): + """Per-IP budget for ACCOUNT-LESS submissions only. + + Returning None for an authenticated caller is what lets these sit in the view's + `throttle_classes` beside the member throttle: DRF applies every class on every + request, and an authenticated member already has a per-principal budget. Without the + skip, a makerspace behind one NAT would rate-limit its own signed-in members by the + shared egress IP. + """ + def get_cache_key(self, request, view): + user = getattr(request, "user", None) + if getattr(user, "is_authenticated", False): + return None ident = self.get_ident(request) return None if not ident else self.cache_format % { "scope": self.scope, diff --git a/backend/apps/integrations/dispatch_channels.py b/backend/apps/integrations/dispatch_channels.py index 81400563..c3f15695 100644 --- a/backend/apps/integrations/dispatch_channels.py +++ b/backend/apps/integrations/dispatch_channels.py @@ -250,7 +250,6 @@ def _deliver_notification(log) -> NotificationDeliveryLog: ok = send_message( log.makerspace, log.text_body, - reply_markup=(log.payload or {}).get("reply_markup"), destination=log.destination, ) else: diff --git a/backend/apps/integrations/models_destinations.py b/backend/apps/integrations/models_destinations.py index 2e72c590..326b7228 100644 --- a/backend/apps/integrations/models_destinations.py +++ b/backend/apps/integrations/models_destinations.py @@ -13,13 +13,15 @@ webhook URL being saved as a chat id. Two nullable columns and a per-channel check constraint make the wrong row unrepresentable. -**Telegram destinations carry NO bot token.** Telegram is bidirectional: the accept/reject -buttons post back to a single registered webhook authenticated by one -`TELEGRAM_WEBHOOK_SECRET`, so a second bot's callbacks cannot be authenticated or routed. -A per-destination token would create rooms that can send but whose buttons are dead, which -in a staff room reads as a broken accept rather than a configuration limit. One bot added -to many groups gives per-machine rooms and keeps callbacks working. Per-bot destinations -would need per-bot webhook secrets and inbound routing — its own phase. +**Telegram destinations carry NO bot token.** The original reason was inbound: accept/reject +buttons posted back to a single registered webhook authenticated by one +`TELEGRAM_WEBHOOK_SECRET`, so a second bot's callbacks could not be authenticated or routed. +**Those buttons are gone** — chat is a notification channel now, not a decision surface — so +that argument has expired, and the rule stands on the outbound half instead: one bot identity +per makerspace across all of its rooms is what makes per-machine rooms read as a single +sender rather than a handful of unrelated bots, and it keeps the token surface to one secret +per tenant. If a button is ever reintroduced, per-bot destinations need per-bot webhook +secrets and inbound routing first — its own phase, exactly as before. **No scope links means space-wide**, which is deliberately the OPPOSITE default to role machine-scope. An unscoped *role* must see nothing (access fails closed); an unscoped diff --git a/backend/apps/integrations/notify.py b/backend/apps/integrations/notify.py index ba559a9c..2a5104e2 100644 --- a/backend/apps/integrations/notify.py +++ b/backend/apps/integrations/notify.py @@ -37,7 +37,6 @@ class EmailDelivery: class LifecyclePayload: text: str emails: tuple[EmailDelivery, ...] = () - telegram_reply_markup: dict | None = None # What this alert is about, for destination scoping. It rides on the payload rather # than being a `notify_lifecycle` parameter because only `build()` has resolved the # domain object — the caller often has just a primary key. `None` means the alert @@ -135,12 +134,6 @@ def _run_guarded(makerspace, feature, event, build, sync): if not enabled[channel]: continue try: - payload_data = ( - {"reply_markup": payload.telegram_reply_markup} - if channel == NotificationChannel.TELEGRAM - and payload.telegram_reply_markup - else None - ) logs = dispatch_channel( makerspace=makerspace, channel=channel, @@ -153,7 +146,6 @@ def _run_guarded(makerspace, feature, event, build, sync): if channel == NotificationChannel.NATIVE_PUSH else chat_text ), - payload=payload_data, sync=sync, scope=payload.scope, ) diff --git a/backend/apps/integrations/telegram.py b/backend/apps/integrations/telegram.py index ae034b4d..f564b904 100644 --- a/backend/apps/integrations/telegram.py +++ b/backend/apps/integrations/telegram.py @@ -14,13 +14,15 @@ class TelegramDeliveryError(Exception): def resolve_bot_token(makerspace): - """The bot a makerspace posts as. + """The bot a makerspace posts as: its own token, else the deployment's. - Destinations deliberately do NOT override this (D16). Telegram is bidirectional: the - accept/reject buttons post back to one registered webhook authenticated by a single - `TELEGRAM_WEBHOOK_SECRET`, so a second bot's callbacks could not be authenticated or - routed — a room on its own bot could send, but its buttons would be dead. One bot in - many groups gives per-machine rooms and keeps callbacks working. + Destinations deliberately do NOT override this (D16). The original reason was + inbound: accept/reject buttons posted back to one registered webhook authenticated by + a single `TELEGRAM_WEBHOOK_SECRET`, so a second bot's callbacks could not be + authenticated or routed. **Those buttons are gone** — chat is no longer an action + surface — but the rule stands on its outbound half: one bot identity per makerspace + across all of its rooms is what makes per-machine rooms read as one sender rather + than as a handful of unrelated bots, and it keeps the token surface to one secret. """ token = ( makerspace.get_telegram_bot_token() @@ -36,7 +38,7 @@ def resolve_chat_id(makerspace, destination=None): return getattr(makerspace, "telegram_group_chat_id", "") -def send_message(makerspace, text, reply_markup=None, destination=None): +def send_message(makerspace, text, destination=None): token = resolve_bot_token(makerspace) chat_id = resolve_chat_id(makerspace, destination) if not token or not chat_id: @@ -51,9 +53,10 @@ def send_message(makerspace, text, reply_markup=None, destination=None): return False base_url = getattr(settings, "TELEGRAM_API_URL", "https://api.telegram.org").rstrip("/") + # No `reply_markup`: Telegram is a notification channel here, and an inline keyboard + # is by definition an action surface. The accept/reject buttons were removed with the + # callback route that served them. payload = {"chat_id": chat_id, "text": trim_for_channel("telegram", text)} - if reply_markup: - payload["reply_markup"] = reply_markup try: body = json.dumps(payload).encode() req = urllib_request.Request( diff --git a/backend/apps/integrations/views.py b/backend/apps/integrations/views.py index 2bf43681..3af616ed 100644 --- a/backend/apps/integrations/views.py +++ b/backend/apps/integrations/views.py @@ -1,4 +1,5 @@ import hmac +import logging from django.conf import settings from django.shortcuts import get_object_or_404 @@ -10,8 +11,6 @@ from apps.accounts import rbac from apps.accounts.models import User -from apps.hardware_requests import workflow -from apps.hardware_requests.models import HardwareRequest from apps.integrations.serializers import ( TelegramTestAlertSerializer, TelegramWebhookSerializer, @@ -19,46 +18,53 @@ from apps.integrations.telegram import TelegramDeliveryError, send_message from apps.makerspaces.models import Makerspace from apps.makerspaces.guards import require_module -from apps.tenant_migration.gate_runtime import tenant_write + +logger = logging.getLogger(__name__) class TelegramWebhookView(APIView): + """Accept-and-ignore. Telegram is a notification channel, not an action surface. + + The accept/reject buttons are gone and this endpoint no longer touches the request + workflow. **The route is kept anyway**, deliberately: a deployment that has already + called `setWebhook` has this URL registered with Telegram, we cannot call + `deleteWebhook` on its behalf, and Telegram retries a non-2xx response for hours. A + 200 acknowledgement is the graceful retirement; a 404 would be a permanent error loop + in someone else's infrastructure. + + The secret check stays for the same reason it was added -- `from.id` in the payload is + attacker-controllable and the endpoint must stay closed to strangers -- and because + the moment a callback DID something again, an endpoint that had quietly stopped + checking would be the vulnerability. + """ + permission_classes = [AllowAny] throttle_classes = [ScopedRateThrottle] throttle_scope = "telegram_webhook" @extend_schema( tags=["Telegram"], - summary="Receive Telegram callback webhook", + summary="Acknowledge a Telegram webhook (no action is taken)", + description=( + "Retained so an already-registered webhook does not retry forever. Callback " + "queries are acknowledged and discarded: accepting and rejecting borrow " + "requests happens in the staff console, never from chat." + ), auth=[], request=TelegramWebhookSerializer, - responses={200: OpenApiResponse(description="Webhook processed.")}, + responses={200: OpenApiResponse(description="Acknowledged; no action taken.")}, ) def post(self, request, *args, **kwargs): if not _webhook_secret_ok(request): return Response({"detail": "Invalid webhook secret."}, status=403) serializer = TelegramWebhookSerializer(data=request.data) serializer.is_valid(raise_exception=True) - callback = serializer.validated_data.get("callback_query") - if not callback: - return Response({"detail": "Ignored."}) - - actor = _telegram_actor(callback) - action, request_id, reason = _parse_callback(callback.get("data", "")) - hardware_request = get_object_or_404(HardwareRequest, pk=request_id) - with tenant_write(hardware_request.makerspace_id): - require_module(hardware_request.makerspace, "telegram") - if action == "accept": - if not rbac.can(actor, rbac.Action.ACCEPT_REQUEST, hardware_request.makerspace_id): - return Response({"detail": "Permission denied."}, status=403) - workflow.accept_request(actor, hardware_request) - elif action == "reject": - if not rbac.can(actor, rbac.Action.REJECT_REQUEST, hardware_request.makerspace_id): - return Response({"detail": "Permission denied."}, status=403) - workflow.reject_request(actor, hardware_request, reason or "Rejected from Telegram.") - else: - return Response({"detail": "Unsupported action."}, status=400) - return Response({"detail": "Processed."}) + if serializer.validated_data.get("callback_query"): + logger.info( + "Telegram callback ignored; chat is not an action surface.", + extra={"has_callback": True}, + ) + return Response({"detail": "Ignored."}) class TelegramTestAlertView(APIView): @@ -114,42 +120,11 @@ def post(self, request, *args, **kwargs): def _webhook_secret_ok(request): # Telegram echoes the secret_token configured at setWebhook time in this header. - # Fail closed when unset: `from.id` in the payload is attacker-controllable, so - # without this the accept/reject workflow could be driven by anyone. + # Fail closed when unset. Nothing behind this endpoint acts any more, but `from.id` + # in the payload is attacker-controllable, and an endpoint that quietly stopped + # authenticating is exactly what would turn a future callback into a vulnerability. secret = settings.TELEGRAM_WEBHOOK_SECRET if not secret: return False provided = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "") return hmac.compare_digest(provided, secret) - - -def _telegram_actor(callback): - from rest_framework.exceptions import PermissionDenied - - telegram_id = str((callback.get("from") or {}).get("id") or "") - # Guard the empty id: filtering on "" would match every user with a blank - # telegram_user_id. Also require active standing so a suspended/restricted or - # deactivated staffer can't drive accept/reject from an old inline button. - if not telegram_id: - raise PermissionDenied("Telegram actor is not linked to a staff user.") - actor = User.objects.filter( - telegram_user_id=telegram_id, - is_active=True, - access_status=User.AccessStatus.ACTIVE, - ).first() - if not actor: - raise PermissionDenied("Telegram actor is not linked to an active staff user.") - return actor - - -def _parse_callback(data): - parts = str(data).split(":", 2) - if len(parts) < 2: - return None, None, "" - action = parts[0] - try: - request_id = int(parts[1]) - except ValueError: - request_id = None - reason = parts[2] if len(parts) > 2 else "" - return action, request_id, reason diff --git a/backend/apps/makerspaces/anonymous_requesters.py b/backend/apps/makerspaces/anonymous_requesters.py index b41e99ce..b7dde6e7 100644 --- a/backend/apps/makerspaces/anonymous_requesters.py +++ b/backend/apps/makerspaces/anonymous_requesters.py @@ -10,6 +10,26 @@ SENTINEL_DISPLAY_NAME = "Anonymous requester (system principal)" +def anonymous_requester_ids(makerspace_ids=None): + """The sentinel user ids, for excluding them from per-PERSON aggregates. + + Every account-less request in a makerspace points at ONE principal, so any report + that groups by `requester_id` folds every unrelated stranger into a single fictional + human -- one "repeat offender", one "top borrower". That row is not a person: it + cannot be contacted, cannot be ranked against real borrowers, and must never be + restricted (doing so would restrict every future account-less requester at once, + which is why `accounts.principal_guards.refuse_anonymous_requester_access_mutation` + exists). Reports exclude these ids instead. + + One query. `makerspace_ids=None` means every makerspace, which is what the + organization-wide rankings need. + """ + queryset = Makerspace.objects.exclude(anonymous_requester__isnull=True) + if makerspace_ids is not None: + queryset = queryset.filter(pk__in=makerspace_ids) + return set(queryset.values_list("anonymous_requester_id", flat=True)) + + def get_or_create_anonymous_requester(makerspace): """Return the makerspace's sentinel, creating it under the tenant row lock. diff --git a/backend/apps/makerspaces/management/commands/list_modules.py b/backend/apps/makerspaces/management/commands/list_modules.py index 75122907..fa8f1ef4 100644 --- a/backend/apps/makerspaces/management/commands/list_modules.py +++ b/backend/apps/makerspaces/management/commands/list_modules.py @@ -1,8 +1,11 @@ +import json + from django.core.management.base import BaseCommand, CommandError from apps.makerspaces.models import Makerspace from apps.makerspaces.module_install import module_status from apps.makerspaces.module_profiles import PROFILES +from apps.makerspaces.request_access import effective_policy class Command(BaseCommand): @@ -10,9 +13,31 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("--makerspace", default=None, help="Makerspace slug (default: the only one).") + parser.add_argument( + "--json", + action="store_true", + help="Emit machine-readable state instead of the operator listing.", + ) def handle(self, *args, **options): makerspace = resolve_makerspace(options["makerspace"]) + if options["json"]: + # `setup.sh` reads its module state back from here rather than parsing the + # `*`/`+`/`-` marks below. That listing is formatted for a human and has + # already been re-laid-out once; a shell script keying on its punctuation + # would break silently the next time it is. + self.stdout.write( + json.dumps( + { + "makerspace": makerspace.slug, + "installed": sorted( + row["key"] for row in module_status(makerspace) if row["installed"] + ), + "request_access": effective_policy(makerspace), + } + ) + ) + return self.stdout.write(f"Modules for {makerspace.slug}:") for row in module_status(makerspace): if row["core"]: diff --git a/backend/apps/makerspaces/management/commands/set_request_access.py b/backend/apps/makerspaces/management/commands/set_request_access.py new file mode 100644 index 00000000..e5a41440 --- /dev/null +++ b/backend/apps/makerspaces/management/commands/set_request_access.py @@ -0,0 +1,79 @@ +"""Set who may submit borrow requests, and report the policy that actually resulted. + +Called by `setup.sh` AFTER the module tick list has been applied, never before: the +answer to "who can submit?" is only partly this flag, and the rest is the `membership` +module the operator may have just ticked. Deriving the answer from the live row is what +keeps the installer from reporting a policy the database does not have. +""" + +from django.core.management.base import BaseCommand, CommandError + +from apps.makerspaces.management.commands.list_modules import resolve_makerspace +from apps.makerspaces.request_access import ( + ACCOUNTS, + ANYONE, + MEMBERS, + POLICY_LABELS, + RequestAccessConflict, + effective_policy, + set_anonymous_requests, +) + +MODES = (MEMBERS, ACCOUNTS, ANYONE) + + +class Command(BaseCommand): + help = "Set who may submit borrow requests for a makerspace." + + def add_arguments(self, parser): + parser.add_argument("--makerspace", default=None, help="Makerspace slug (default: the only one).") + parser.add_argument( + "--mode", + required=True, + choices=MODES, + help=( + "members = active members only (requires the membership module); " + "accounts = any signed-in account; anyone = no account needed." + ), + ) + + def handle(self, *args, **options): + makerspace = resolve_makerspace(options["makerspace"]) + mode = options["mode"] + try: + # Only `anyone` is a request to OPEN the flag. `members` and `accounts` are + # both "an account is required", and which of the two you get is decided by + # the membership module, not by this command -- so both close the flag and + # then report what the module state actually produced. + resulting = set_anonymous_requests(makerspace, mode == ANYONE) + except RequestAccessConflict as exc: + raise CommandError(str(exc)) from exc + + self.stdout.write( + self.style.SUCCESS( + f"Borrow requests for {makerspace.slug}: {POLICY_LABELS[resulting]}." + ) + ) + if resulting != mode: + # Never silently: asking for `members` on a space without the membership + # module leaves submission open to any signed-in account, and an operator + # who is not told that believes they closed something they did not. + self.stdout.write( + self.style.WARNING( + f"You asked for '{mode}' but the live module state produces " + f"'{resulting}'. " + + ( + "Install the `membership` module to restrict submission to " + "members." + if resulting == ACCOUNTS + else "Uninstall the `membership` module to allow account-less " + "requests." + ) + ) + ) + return None + + +def current_policy(makerspace) -> str: + """Shared with the installer's read-back step.""" + return effective_policy(makerspace) diff --git a/backend/apps/makerspaces/migrations/0067_reconcile_anonymous_requests_with_membership.py b/backend/apps/makerspaces/migrations/0067_reconcile_anonymous_requests_with_membership.py new file mode 100644 index 00000000..40d43b1d --- /dev/null +++ b/backend/apps/makerspaces/migrations/0067_reconcile_anonymous_requests_with_membership.py @@ -0,0 +1,43 @@ +"""Close the membership + account-less-requests pair on rows that already carry both. + +`Makerspace.save()` now makes the combination unreachable, but that only guards writes +from here on. A row written before the rule — by the `/control/` capability matrix, a +module install, or a restored backup — can still be sitting in the impossible state, and +`RequestSubmitView` would have admitted a stranger past the membership requirement. + +Fixed in SQL rather than through the model on purpose: this must not depend on `save()` +(the thing being backfilled for), and it must not fan out per-row on a large install. +""" + +from django.db import migrations + + +def close_anonymous_requests_where_membership_installed(apps, schema_editor): + Makerspace = apps.get_model("makerspaces", "Makerspace") + # `enabled_modules` is a JSONField holding a list of keys, so containment is the + # right test — `contains` compiles to the jsonb @> operator. + Makerspace.objects.filter( + anonymous_requests_enabled=True, + enabled_modules__contains=["membership"], + ).update(anonymous_requests_enabled=False) + + +def noop_reverse(apps, schema_editor): + """Deliberately not reversed. + + Re-opening account-less requests on a makerspace that has `membership` installed is + the security hole this migration exists to close; a downgrade must not reinstate it. + Reversing the code change is enough to restore the old behaviour for anyone who + re-enables the flag on purpose. + """ + + +class Migration(migrations.Migration): + dependencies = [("makerspaces", "0066_makerspace_anonymous_requester_and_more")] + + operations = [ + migrations.RunPython( + close_anonymous_requests_where_membership_installed, + noop_reverse, + ), + ] diff --git a/backend/apps/makerspaces/models_makerspace.py b/backend/apps/makerspaces/models_makerspace.py index 516e7468..469488d1 100644 --- a/backend/apps/makerspaces/models_makerspace.py +++ b/backend/apps/makerspaces/models_makerspace.py @@ -16,6 +16,7 @@ ) from apps.makerspaces.module_registry import default_enabled_module_keys from apps.makerspaces.provenance import validate_actor_snapshot +from apps.makerspaces.request_access import reconcile_enabled_modules from apps.makerspaces.models_makerspace_secrets import MakerspaceSecretsMixin from apps.makerspaces.validators import ( DEFAULT_PRESENCE_PRESETS, @@ -77,6 +78,11 @@ class PublicPrintStatusLookupPolicy(models.TextChoices): # Account-less requests are an unauthenticated write surface, so this is an # independent opt-in. In particular, it must not follow the membership module: # recommended installs omit that module and must stay closed after an upgrade. + # + # The reverse direction IS coupled, and `save()` enforces it: installing + # `membership` forces this off, because the anonymous branch of RequestSubmitView + # runs before any membership guard and would otherwise walk straight past the + # requirement the operator just switched on. See `request_access`. anonymous_requests_enabled = models.BooleanField(default=False) public_stats_enabled = models.BooleanField(default=False) public_stats_show_holder_names = models.BooleanField(default=False) @@ -232,6 +238,22 @@ def geofence_effective(self) -> bool: def save(self, *args, **kwargs): self.public_code = (self.public_code or "").upper() self.frontend_domain = normalize_frontend_domain(self.frontend_domain) + # Membership and account-less requests are mutually exclusive, and this is the + # ONE chokepoint every writer passes through: module install/uninstall, profile + # application, the /control/ capability matrix, setup_instance, seed_demo and a + # plain obj.save(). Enforcing it here rather than in each of them is what makes + # the state unreachable instead of merely discouraged -- see + # `request_access` for why the pair is impossible. + reconciled = reconcile_enabled_modules( + self.enabled_modules, self.anonymous_requests_enabled + ) + if reconciled != self.anonymous_requests_enabled: + self.anonymous_requests_enabled = reconciled + # A partial save that did not name this field would otherwise change the + # attribute in memory and leave the row in the impossible state. + update_fields = kwargs.get("update_fields") + if update_fields is not None: + kwargs["update_fields"] = [*update_fields, "anonymous_requests_enabled"] super().save(*args, **kwargs) def clean(self): diff --git a/backend/apps/makerspaces/module_registry.py b/backend/apps/makerspaces/module_registry.py index f0341c86..1fd814f8 100644 --- a/backend/apps/makerspaces/module_registry.py +++ b/backend/apps/makerspaces/module_registry.py @@ -62,7 +62,7 @@ frontend_workflows=("printing_requests",), ), ModuleDefinition( - "telegram", "Telegram", "Per-makerspace Telegram group alerts and callbacks.", + "telegram", "Telegram", "Per-makerspace Telegram group alerts (outbound only).", "integrations", GUARD, group=GROUP_NOTIFICATIONS, frontend_workflows=("telegram_alerts",), ), ModuleDefinition( @@ -246,6 +246,27 @@ def _validate_registry(): raise ImproperlyConfiguredRegistry( f"{definition.key} is core, which already implies default_enabled." ) + # A core module is present in EVERY makerspace and cannot be switched off. If it + # declared a dependency on an optional key, either that key is really core (and + # is mislabelled), or the core module stops working the moment an operator + # uninstalls something they were told was optional. `9e496997` was that bug + # without the declaration: core `request_workflow` hard-required a + # MakerspaceMembership row, and the default `recommended` profile ships no + # `membership` module -- so a fresh self-host install could not accept a public + # borrow request at all. This catches the declared form of it at import time; + # `tests/makerspaces/test_core_module_independence.py` covers the undeclared form. + if definition.is_core: + optional_requirements = [ + key + for key in definition.requires_modules + if key in BY_KEY and not BY_KEY[key].is_core + ] + if optional_requirements: + raise ImproperlyConfiguredRegistry( + f"{definition.key} is core but requires optional module(s): " + f"{', '.join(sorted(optional_requirements))}. A core module must " + "work with every optional module uninstalled." + ) if definition.enforcement not in {GUARD, FEATURE_PARENT, NONE}: raise ImproperlyConfiguredRegistry( f"{definition.key} has unknown enforcement {definition.enforcement!r}." diff --git a/backend/apps/makerspaces/request_access.py b/backend/apps/makerspaces/request_access.py new file mode 100644 index 00000000..eec65387 --- /dev/null +++ b/backend/apps/makerspaces/request_access.py @@ -0,0 +1,121 @@ +"""Who may submit a borrow request — the single answer, in one place. + +Three states, and they are DERIVED, not stored. Only ``anonymous_requests_enabled`` is a +column; the rest falls out of whether the ``membership`` module is installed: + +=========== ===================== ============================================ +`membership` `anonymous_requests_…` who may submit +=========== ===================== ============================================ +on off ``members`` — an active MakerspaceMembership +off off ``accounts`` — any active authenticated account +off on ``anyone`` — no account at all +on **on** impossible — see below +=========== ===================== ============================================ + +The fourth row is the reason this module exists. `RequestSubmitView` takes the anonymous +branch *before* it reaches any membership guard, so a row carrying both settings would let +a stranger walk straight past the membership requirement the operator had just switched on +— the opposite of what enabling `membership` means. Turning one on therefore turns the +other off, and it is enforced at three depths so no write path can reconstruct the state: + +1. ``Makerspace.save()`` calls :func:`reconcile_enabled_modules` — the model cannot be + persisted in the impossible state by ANY caller (module install/uninstall, profile + application, the ``/control/`` capability matrix, ``setup_instance``, ``seed_demo``, a + plain ``obj.save()``). +2. :func:`set_anonymous_requests` is the only *deliberate* writer, and it takes the row + lock so a concurrent membership install cannot land between its check and its save. +3. :func:`anonymous_requests_allowed` re-derives the answer at request time, so a row + written by raw SQL or restored from an old backup still fails closed. + +Forced, never refused: refusing a membership install because of an unrelated public-access +flag would block a legitimate module change from the console and the setup tick list. The +change is audited with the before/after policy so it is never silent. +""" + +MEMBERSHIP_MODULE = "membership" + +MEMBERS = "members" +ACCOUNTS = "accounts" +ANYONE = "anyone" + +POLICY_LABELS = { + MEMBERS: "active members of this makerspace", + ACCOUNTS: "anyone with a signed-in account", + ANYONE: "anyone, no account needed", +} + + +def membership_installed(enabled_modules) -> bool: + """Pure predicate over the stored key list. + + Deliberately NOT ``platform.module_enabled``: that also asks whether the deployment + still ships the app, and this is the fail-closed direction. A tenant that asked for + membership must not have strangers admitted just because the app is tombstoned, and + the model layer cannot import ``platform`` anyway (``platform`` imports the models). + """ + return MEMBERSHIP_MODULE in set(enabled_modules or []) + + +def reconcile_enabled_modules(enabled_modules, anonymous_requests_enabled) -> bool: + """The resulting ``anonymous_requests_enabled``. Membership wins.""" + return bool(anonymous_requests_enabled) and not membership_installed(enabled_modules) + + +def policy_for(enabled_modules, anonymous_requests_enabled) -> str: + if reconcile_enabled_modules(enabled_modules, anonymous_requests_enabled): + return ANYONE + return MEMBERS if membership_installed(enabled_modules) else ACCOUNTS + + +def effective_policy(makerspace) -> str: + """Who may submit to this makerspace right now.""" + return policy_for(makerspace.enabled_modules, makerspace.anonymous_requests_enabled) + + +def anonymous_requests_allowed(makerspace) -> bool: + """Request-time check. Re-derived rather than trusting the column alone.""" + return effective_policy(makerspace) == ANYONE + + +class RequestAccessConflict(Exception): + """Account-less requests were asked for while `membership` is installed.""" + + +def set_anonymous_requests(makerspace, enabled, *, actor=None): + """Deliberately set the flag under the row lock, and audit the policy change. + + Returns the effective policy AFTER the write. Raises :class:`RequestAccessConflict` + when asked to open account-less requests on a makerspace that has `membership` + installed — an explicit operator request is refused loudly rather than silently + downgraded, which is the opposite of the module-install path (that one forces, because + the operator was asking about modules, not about this flag). + """ + from django.db import transaction + + from apps.audit import services as audit + from apps.makerspaces.models import Makerspace + + with transaction.atomic(): + locked = Makerspace.objects.select_for_update().get(pk=makerspace.pk) + before = effective_policy(locked) + wanted = bool(enabled) + if wanted and membership_installed(locked.enabled_modules): + raise RequestAccessConflict( + "The membership module is installed, so borrow requests require an " + "account. Uninstall `membership` first, or leave account-less requests " + "off." + ) + if locked.anonymous_requests_enabled != wanted: + locked.anonymous_requests_enabled = wanted + locked.save(update_fields=["anonymous_requests_enabled"]) + after = effective_policy(locked) + if before != after: + audit.record( + actor, + "makerspace.request_access_changed", + makerspace=locked, + target=locked, + meta={"before": before, "after": after}, + ) + makerspace.refresh_from_db(fields=["anonymous_requests_enabled"]) + return after diff --git a/backend/apps/operations/accountability.py b/backend/apps/operations/accountability.py index 9200fb72..5185b494 100644 --- a/backend/apps/operations/accountability.py +++ b/backend/apps/operations/accountability.py @@ -3,20 +3,44 @@ from django.utils import timezone from apps.accounts.models import User +from apps.hardware_requests.display import requester_label from apps.hardware_requests.models import ( HardwareRequest, PublicProblemReport, PublicToolLoan, RequesterAccountability, ) +from apps.makerspaces.anonymous_requesters import anonymous_requester_ids + + +def _anonymous_accountability(makerspace_id, principals): + """Damage and loss recorded against account-less requests, as one total. + + Not a ranking row and deliberately not shaped like one: there is no person to name, + contact or restrict here, only a count of what strangers did not bring back. + """ + totals = RequesterAccountability.objects.filter( + makerspace_id=makerspace_id, requester_id__in=principals + ).aggregate( + damaged=Count("id", filter=Q(issue_type=RequesterAccountability.IssueType.DAMAGED)), + missing=Count("id", filter=Q(issue_type=RequesterAccountability.IssueType.MISSING)), + total_issues=Count("id"), + total_quantity=Coalesce(Sum("quantity"), 0), + ) + return {key: value or 0 for key, value in totals.items()} def accountability_data(makerspace_id, *, limit=200): - repeat_offenders, repeat_truncated = _repeat_offenders(makerspace_id, limit) + principals = anonymous_requester_ids([makerspace_id]) + repeat_offenders, repeat_truncated = _repeat_offenders(makerspace_id, limit, principals) overdue, overdue_truncated = _overdue_loans(makerspace_id, limit) problem_reports, problems_truncated = _problem_reports(makerspace_id, limit) return { "repeat_offenders": repeat_offenders, + # The damage and loss that the excluded principal was carrying, kept as a total + # rather than dropped: an accountability panel that silently lost every + # account-less incident would be worse than one that named a fictional person. + "anonymous_accountability": _anonymous_accountability(makerspace_id, principals), "overdue": overdue, "restrictions": _restricted_requesters(makerspace_id), "problem_reports": problem_reports, @@ -57,9 +81,12 @@ def _problem_reports(makerspace_id, limit): ], len(rows) > limit -def _repeat_offenders(makerspace_id, limit): +def _repeat_offenders(makerspace_id, limit, principals): rows = list( + # Excluded BEFORE the limit, not after: filtering a materialized page would let + # the principal push a real repeat offender off the end of the list. RequesterAccountability.objects.filter(makerspace_id=makerspace_id) + .exclude(requester_id__in=principals) .values( "requester_id", "requester__username", @@ -125,7 +152,10 @@ def _overdue_requests(makerspace_id, now, limit): { "type": "request", "reference_id": request.id, - "requester_username": request.requester.username, + # `requester_label` prefers the request's own contact snapshot, so an + # account-less overdue loan shows the email the borrower actually gave + # instead of the principal's internal `member_` username. + "requester_username": requester_label(request), "label": _request_label(request), "due_at": request.return_due_at, } @@ -160,6 +190,10 @@ def _request_label(request): def _restricted_requesters(makerspace_id): + # The principal can never legitimately appear here -- restrict/restore both refuse it + # via `refuse_anonymous_requester_access_mutation` -- but it is excluded anyway, so a + # row created before that guard, or by a direct DB edit, cannot render as a person. + principals = anonymous_requester_ids([makerspace_id]) return [ { "requester_id": user.id, @@ -167,7 +201,7 @@ def _restricted_requesters(makerspace_id): "access_status": user.access_status, "restriction_reason": user.restriction_reason, } - for user in User.objects.filter( + for user in User.objects.exclude(pk__in=principals).filter( accountability_records__makerspace_id=makerspace_id, access_status__in=[ User.AccessStatus.RESTRICTED, diff --git a/backend/apps/operations/org_report_identity.py b/backend/apps/operations/org_report_identity.py index d3d3a9ef..0efba82f 100644 --- a/backend/apps/operations/org_report_identity.py +++ b/backend/apps/operations/org_report_identity.py @@ -5,6 +5,7 @@ from apps.hardware_requests.display import label_from_candidates from apps.hardware_requests.models import HardwareRequest, HardwareRequestItem +from apps.makerspaces.anonymous_requesters import anonymous_requester_ids from apps.makerspaces.models import MakerspaceMembership, MembershipRequest from apps.operations.report_scope import ReportScope, scope_queryset @@ -19,6 +20,10 @@ def globally_ranked_borrowers(scope: ReportScope, *, date_range, limit): makerspace_field="request__makerspace_id", ) items = _range(items, "request__issued_at", date_range) + # Same reason as the per-space ranking: the shared account-less principal is not a + # person and must not occupy a rank. Excluded before the slice so it cannot displace + # a real borrower from the top `limit`. + items = items.exclude(request__requester_id__in=anonymous_requester_ids()) ranked = list( items.values("request__requester_id") .annotate( diff --git a/backend/apps/operations/reports_inventory.py b/backend/apps/operations/reports_inventory.py index de8273dd..0e388583 100644 --- a/backend/apps/operations/reports_inventory.py +++ b/backend/apps/operations/reports_inventory.py @@ -1,6 +1,7 @@ from django.db.models import Count, Sum from apps.boxes.models import QrScanEvent +from apps.makerspaces.anonymous_requesters import anonymous_requester_ids from apps.hardware_requests.display import label_from_candidates, requester_label from apps.hardware_requests.models import HardwareRequest, HardwareRequestItem from apps.inventory.models import InventoryAsset, InventoryProduct @@ -179,6 +180,10 @@ def _top_borrowers(makerspace_id, aggregate, limit=None, date_range=None): qs = ( _apply_date_range(_items(makerspace_id), "request__issued_at", date_range) .filter(issued_quantity__gt=0) + # Every account-less request in a space shares ONE requester principal, so + # without this they rank as a single fictional "top borrower". Excluded before + # the grouping so the ranking and any limit are computed over real people only. + .exclude(request__requester_id__in=anonymous_requester_ids()) .values(*values) .annotate( requests=Count("request_id", distinct=True), diff --git a/backend/apps/tenant_migration/gate_policy.py b/backend/apps/tenant_migration/gate_policy.py index d063e670..cd134e60 100644 --- a/backend/apps/tenant_migration/gate_policy.py +++ b/backend/apps/tenant_migration/gate_policy.py @@ -19,6 +19,12 @@ ), "backup-recovery-state": "Deployment recovery must be able to quarantine/recover the deployment.", "stripe-connect-webhook": "Connect account events are platform routing state, not one source tenant.", + "telegram-webhook": ( + "Acknowledge-and-discard: the callback route was removed when chat stopped being an " + "action surface, so this writes nothing and cannot break quiescence. It must keep " + "answering 200 even mid-migration, or Telegram retries an already-registered webhook " + "for hours against a tenant that is being moved." + ), "auth-login": "Global user session state is excluded from tenant quiescence.", "auth-refresh": "Global user session state is excluded from tenant quiescence.", "auth-logout": "Global user session state is excluded from tenant quiescence.", @@ -64,6 +70,11 @@ "apps.accounts.views_social.SocialNonceView.post": "Global social-login nonce state.", "apps.accounts.views_social.SocialLoginView.post": "Global social identity and session state.", "apps.payments.views_connect.StripeConnectWebhookView.post": "Platform Connect routing state.", + "apps.integrations.views.TelegramWebhookView.post": ( + "Writes nothing at all: the callback route was removed when chat stopped being an " + "action surface, and the view only acknowledges so an already-registered webhook " + "stops retrying. No tenant state is reachable from it to quiesce." + ), } diff --git a/backend/apps/tenant_migration/source_gate_guards.py b/backend/apps/tenant_migration/source_gate_guards.py index 3a4afaaf..400bbab2 100644 --- a/backend/apps/tenant_migration/source_gate_guards.py +++ b/backend/apps/tenant_migration/source_gate_guards.py @@ -113,6 +113,8 @@ def validate_webhook_coverage(apps_dir=APPS_DIR): exemptions = { "apps.payments.views_connect.StripeConnectWebhookView.post": HTTP_EXEMPTIONS["stripe-connect-webhook"], + "apps.integrations.views.TelegramWebhookView.post": + HTTP_EXEMPTIONS["telegram-webhook"], } stale = set(exemptions) - actual if stale: diff --git a/backend/apps/tenant_migration/tenant_dump_catalog.py b/backend/apps/tenant_migration/tenant_dump_catalog.py index d06f4cd1..646fe59c 100644 --- a/backend/apps/tenant_migration/tenant_dump_catalog.py +++ b/backend/apps/tenant_migration/tenant_dump_catalog.py @@ -38,7 +38,7 @@ class TenantDumpCatalogError(AssertionError): # SHA-256 of the ordered model/table/field graph produced by ``catalog_schema``. # Updating it is an explicit review act; runtime introspection never blesses drift. -CATALOG_SCHEMA_SHA256 = "0672f2c998f121c610ac372c80e8f87ebd674f78339c57d59de7657b17f3e5cf" +CATALOG_SCHEMA_SHA256 = "2f17b431d479fbbb508361dae0ebd465cc87163946bf64e2efeead65557d0188" def catalog_models(apps_registry=apps): diff --git a/backend/templates/admin/hardware_requests/reject_action.html b/backend/templates/admin/hardware_requests/reject_action.html deleted file mode 100644 index 1c40dc59..00000000 --- a/backend/templates/admin/hardware_requests/reject_action.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "admin/base_site.html" %} - -{% block content %} -
- {% csrf_token %} - - {% for obj in queryset %} - - {% endfor %} - -
-
- - -
-
- -
- -
-
-{% endblock %} diff --git a/backend/templates/admin/hardware_requests/review.html b/backend/templates/admin/hardware_requests/review.html new file mode 100644 index 00000000..78ea0982 --- /dev/null +++ b/backend/templates/admin/hardware_requests/review.html @@ -0,0 +1,76 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
+

Requester

+ + + + + + + + + + + + +
Name{{ hardware_request.requester_name|default:"—" }}
Email{{ hardware_request.requester_contact_email|default:"—" }}
Phone{{ hardware_request.requester_contact_phone|default:"—" }}
Contact + {% if hardware_request.requester_contact_verified %} + Verified + {% else %} + NOT VERIFIED — this contact is only a claim made by whoever + submitted the request. Nothing proves the address or number belongs to them, and + accepting does not verify it. Confirm identity before handing anything over. + {% endif %} +
Makerspace{{ hardware_request.makerspace }}
Status{{ hardware_request.status }}
Requested for{{ hardware_request.requested_for|default:"—" }}
Submitted{{ hardware_request.created_at }}
+
+ +{% if is_pending %} +
+ {% csrf_token %} + +
+

Items

+ + + + + + {% for item in items %} + + + + + + {% empty %} + + {% endfor %} + +
ProductRequestedAccept
{{ item.product.name }}{{ item.requested_quantity }} + +
This request has no items.
+
+ +
+

Reject instead

+
+ + +
+
+ +
+ + +
+
+{% else %} +

+ This request is {{ hardware_request.status }}, so there is nothing left + to review. Accept and reject apply to pending requests only. +

+{% endif %} +{% endblock %} diff --git a/backend/tests/backup/test_archive_digests.py b/backend/tests/backup/test_archive_digests.py index 68beb5c3..98b9a30e 100644 --- a/backend/tests/backup/test_archive_digests.py +++ b/backend/tests/backup/test_archive_digests.py @@ -100,7 +100,17 @@ def snapshot(_archive, root, _modes, selected_recipients): "storage": {"objects": objects}, } + real_run = archive_builder.subprocess.run + def encrypt(command, **_kwargs): + if "-o" not in command: + # Not an age invocation. This fake replaces the SHARED subprocess module, so + # every other binary the code runs lands here too -- including + # `postgres_client._binary_major`'s ` --version` probe. Delegate + # instead of stubbing: a bare SimpleNamespace has no `.stdout`, which turned a + # clean PostgresClientUnavailable into an AttributeError on every host without + # a versioned client directory (any non-Debian/RHEL host). + return real_run(command, **_kwargs) output = Path(command[command.index("-o") + 1]) shutil.copyfile(command[-1], output) return SimpleNamespace(returncode=0) diff --git a/backend/tests/backup/test_archive_recipient_selection.py b/backend/tests/backup/test_archive_recipient_selection.py index d2fd333c..0812b41e 100644 --- a/backend/tests/backup/test_archive_recipient_selection.py +++ b/backend/tests/backup/test_archive_recipient_selection.py @@ -83,8 +83,18 @@ def snapshot(_archive, root, _modes, selected): monkeypatch.setattr(archive_builder, "_snapshot_payload", snapshot) calls = [] + real_run = archive_builder.subprocess.run + def encrypt(args, **_kwargs): calls.append(args) + if "-o" not in args: + # Not an age invocation. This fake replaces the SHARED subprocess module, so + # every other binary the code runs lands here too -- including + # `postgres_client._binary_major`'s ` --version` probe. Delegate + # instead of stubbing: a bare SimpleNamespace has no `.stdout`, which turned a + # clean PostgresClientUnavailable into an AttributeError on every host without + # a versioned client directory (any non-Debian/RHEL host). + return real_run(args, **_kwargs) shutil.copyfile(args[-1], args[args.index("-o") + 1]) return SimpleNamespace(returncode=0) diff --git a/backend/tests/backup/test_compound_archive_e2.py b/backend/tests/backup/test_compound_archive_e2.py index c7feb508..0f1c2dc8 100644 --- a/backend/tests/backup/test_compound_archive_e2.py +++ b/backend/tests/backup/test_compound_archive_e2.py @@ -116,12 +116,18 @@ def tenant_payload(makerspace_id, root): monkeypatch.setattr(archive_payload, "_tenant_payload", tenant_payload) commands = [] + real_run = archive_builder.subprocess.run def fake_age(command, **_kwargs): commands.append(command) if "-o" not in command: - # Not an age invocation (E7 added other subprocess calls); nothing to seal. - return SimpleNamespace(returncode=0) + # Not an age invocation. This fake replaces the SHARED subprocess module, so + # every other binary the code runs lands here too -- including + # `postgres_client._binary_major`'s ` --version` probe. Delegate + # instead of stubbing: a bare SimpleNamespace has no `.stdout`, which turned a + # clean PostgresClientUnavailable into an AttributeError on every host without + # a versioned client directory (any non-Debian/RHEL host). + return real_run(command, **_kwargs) output = Path(command[command.index("-o") + 1]) payload = _kwargs.get("input") if payload is None: diff --git a/backend/tests/backup/test_producer_capability_gate_p1.py b/backend/tests/backup/test_producer_capability_gate_p1.py index 86ab7447..4ea82f5f 100644 --- a/backend/tests/backup/test_producer_capability_gate_p1.py +++ b/backend/tests/backup/test_producer_capability_gate_p1.py @@ -239,7 +239,17 @@ def snapshot(_archive, root, _modes, _selected): (root / "database.dump").write_bytes(b"ordinary backup") return {"format": "spaceworks-phase5a-v3", "storage": {"objects": []}} + real_run = archive_builder.subprocess.run + def encrypt(command, **_kwargs): + if "-o" not in command: + # Not an age invocation. This fake replaces the SHARED subprocess module, so + # every other binary the code runs lands here too -- including + # `postgres_client._binary_major`'s ` --version` probe. Delegate + # instead of stubbing: a bare SimpleNamespace has no `.stdout`, which turned a + # clean PostgresClientUnavailable into an AttributeError on every host without + # a versioned client directory (any non-Debian/RHEL host). + return real_run(command, **_kwargs) shutil.copyfile(command[-1], command[command.index("-o") + 1]) return SimpleNamespace(returncode=0) diff --git a/backend/tests/encryption/test_rollout.py b/backend/tests/encryption/test_rollout.py index 7e548a2e..a69154c1 100644 --- a/backend/tests/encryption/test_rollout.py +++ b/backend/tests/encryption/test_rollout.py @@ -132,7 +132,12 @@ def test_closed_global_fence_rejects_orm_bulk_and_raw_bypass_paths(): def test_rollback_rejects_an_overflow_without_touching_source_or_index(): actor = _actor() with enabled_encryption(): - space, user, row = _request("x" * 121) + # Must exceed the REGISTRY limit for this field (`registry.SOURCE_FIELDS` declares + # requester_name at 200), not the column: the model field is a TextField, so the + # database would accept any length and `validate_legacy_values` is the only thing + # standing between a rollback and a silently truncated legacy row. This read 121 + # while the registered limit was 120, and went quiet when the limit moved to 200. + space, user, row = _request("x" * 201) operation = close_global("decrypt_rollback", actor.pk, all_makerspaces=True) with pytest.raises(ValidationError): call_command("decrypt_scoped_pii", makerspace=space.pk, model=row._meta.label, diff --git a/backend/tests/makerspaces/test_core_module_independence.py b/backend/tests/makerspaces/test_core_module_independence.py new file mode 100644 index 00000000..e1164eb8 --- /dev/null +++ b/backend/tests/makerspaces/test_core_module_independence.py @@ -0,0 +1,194 @@ +"""The core loan spine must survive any single optional module being uninstalled. + +**What this proves, precisely.** For every optional module `M`, a makerspace built from +`core + (every other optional module)` can still run the loan spine end to end: browse the +public catalogue, submit a borrow request, see it in the staff queue, accept it, and read +its public status. Plus the strongest single case — `core` and nothing else. + +**What it does NOT prove.** It is not a general proof that "no core module hard-depends on +an optional module's data". A true static proof is not available in this codebase: an app +label is not the unit of module ownership (`hardware_requests` owns core `request_workflow` +AND optional `guest_handover`; `admin_api` owns core `staff_admin` and many optional +surfaces), so no per-file rule can separate a legitimate optional-module gate from a core +module reaching for optional data. The declared half of the property is checked at import +time instead, by `module_registry._validate_registry` (a core module may not name an +optional one in `requires_modules`). + +This is the regression that `9e496997` shipped and nothing caught: core `request_workflow` +called `require_active_member_presence` unconditionally, that guard hard-requires a +`MakerspaceMembership` row, and the default `recommended` profile installs no `membership` +module — so a fresh self-host install returned 403 `membership_required` to every public +borrow request. The `no_membership` case below fails without that fix. + +**The identity is built to match each configuration, and that is load-bearing.** With +`membership` installed the spine legitimately requires an active member with an open +presence session; a test that always used a plain account would be rejected by the +membership guard in 25 of 26 cases and would prove nothing about the module under test. +""" + +from datetime import timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import User +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace, MakerspaceMembership +from apps.makerspaces.module_registry import BY_KEY, MODULES, core_module_keys +from apps.presence.models import PresenceSession + +pytestmark = pytest.mark.django_db + +CORE = frozenset(core_module_keys()) +OPTIONAL = tuple(sorted(key for key in BY_KEY if key not in CORE)) + + +def transitive_dependents(key): + """Every module that would break if `key` were removed, at any depth. + + `module_registry_helpers.dependents_of` answers one level only. Removing `machines` + while leaving `printing` installed would produce a capability set + `validate_capabilities` rejects, so the closure is what makes each configuration in + this matrix a legal one. + """ + removed = {key} + changed = True + while changed: + changed = False + for definition in MODULES: + if definition.key in removed: + continue + if removed & set(definition.requires_modules): + removed.add(definition.key) + changed = True + return removed - {key} + + +def configuration_without(key): + dropped = {key, *transitive_dependents(key)} + return sorted(CORE | ({*OPTIONAL} - dropped)) + + +def _space(slug, modules): + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=list(modules), + public_inventory_enabled=True, + ) + + +def _requester(slug, space): + """An account that is legitimately allowed to submit under THIS configuration.""" + user = User.objects.create_user( + username=f"cmi-{slug}", + email=f"cmi-{slug}@example.test", + display_name="Spine Requester", + access_status=User.AccessStatus.ACTIVE, + ) + if "membership" in (space.enabled_modules or []): + # Membership installed => the spine legitimately demands an active member with an + # open presence session. No MakerspaceWaiver row is created, so the waiver branch + # of `require_active_member` is not exercised here; that rule has its own tests. + membership = MakerspaceMembership.objects.create( + makerspace=space, user=user, status="active", + ) + now = timezone.now() + PresenceSession.objects.create( + member=user, + makerspace=space, + membership=membership, + started_at=now, + expires_at=now + timedelta(hours=2), + ) + return user + + +def _staff(slug): + return User.objects.create_user( + username=f"cmi-staff-{slug}", + email=f"cmi-staff-{slug}@example.test", + role=User.Role.SUPERADMIN, + is_staff=True, + is_superuser=True, + access_status=User.AccessStatus.ACTIVE, + ) + + +def _client(user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user) + return client + + +def run_loan_spine(slug, modules): + """Browse -> submit -> staff queue -> accept -> public status. Returns nothing; + every step asserts, so the failing step names itself.""" + space = _space(slug, modules) + product = InventoryProduct.objects.create( + makerspace=space, + name="Torque wrench", + total_quantity=3, + available_quantity=3, + is_public=True, + ) + + catalog = _client().get(reverse("inventory:public-inventory", args=[space.slug])) + assert catalog.status_code == 200, f"catalog: {catalog.status_code} {catalog.data}" + + submit = _client(_requester(slug, space)).post( + reverse("hardware_requests:request-submit", args=[space.slug]), + {"requested_for": "Spine check", "items": [{"product_id": product.pk, "quantity": 1}]}, + format="json", + ) + assert submit.status_code == 201, f"submit: {submit.status_code} {submit.data}" + public_token = submit.data["public_token"] + + staff = _client(_staff(slug)) + pending = staff.get(reverse("hardware_requests:pending-requests", args=[space.id])) + assert pending.status_code == 200, f"queue: {pending.status_code} {pending.data}" + assert pending.data["count"] == 1, pending.data + + request_id = pending.data["results"][0]["id"] + accepted = staff.post(reverse("hardware_requests:request-accept", args=[request_id]), {}, format="json") + assert accepted.status_code == 200, f"accept: {accepted.status_code} {accepted.data}" + assert accepted.data["status"] == "accepted" + + status_response = _client().get( + reverse("hardware_requests:request-status", args=[public_token]) + ) + assert status_response.status_code == 200, f"status: {status_response.status_code}" + + +def test_the_loan_spine_runs_on_a_core_only_makerspace(): + """The strongest single case: every optional module uninstalled at once.""" + run_loan_spine("core-only", sorted(CORE)) + + +@pytest.mark.parametrize("missing", OPTIONAL) +def test_the_loan_spine_survives_each_optional_module_being_uninstalled(missing): + run_loan_spine(f"no-{missing.replace('_', '-')}", configuration_without(missing)) + + +def test_every_optional_module_is_actually_covered_by_the_matrix(): + """Guards the guard: a module added to the registry joins the matrix automatically, + and this fails loudly if the core/optional split ever computes to nothing.""" + assert OPTIONAL, "no optional modules found — the matrix would be vacuous" + assert CORE.isdisjoint(OPTIONAL) + assert len(CORE) + len(OPTIONAL) == len(BY_KEY) + + +def test_a_core_module_may_not_declare_a_dependency_on_an_optional_one(): + """The declared half, pinned here so the registry rule cannot be quietly removed.""" + for definition in MODULES: + if not definition.is_core: + continue + optional_requirements = [ + key for key in definition.requires_modules if not BY_KEY[key].is_core + ] + assert not optional_requirements, ( + f"{definition.key} is core but requires {optional_requirements}" + ) diff --git a/backend/tests/makerspaces/test_request_access.py b/backend/tests/makerspaces/test_request_access.py new file mode 100644 index 00000000..f6bf5d11 --- /dev/null +++ b/backend/tests/makerspaces/test_request_access.py @@ -0,0 +1,204 @@ +"""The membership / account-less-requests pair is impossible, at every depth. + +`RequestSubmitView` takes its anonymous branch BEFORE any membership guard runs, so a +makerspace carrying both `membership` and `anonymous_requests_enabled` would let a +stranger walk past the membership requirement the operator had just switched on. These +pin the three enforcement depths independently, because each one covers writers the +others do not: the model rule covers every save, the service covers the deliberate +operator request, and the view re-derives so a row written behind both still fails closed. +""" + +import json +from io import StringIO + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.audit.models import AuditLog +from apps.inventory.models import InventoryProduct +from apps.makerspaces.models import Makerspace +from apps.makerspaces.module_install import install_module, uninstall_module +from apps.makerspaces.request_access import ( + ACCOUNTS, + ANYONE, + MEMBERS, + RequestAccessConflict, + effective_policy, + set_anonymous_requests, +) + +pytestmark = pytest.mark.django_db + + +def _space(slug, *, modules, anonymous=False): + return Makerspace.objects.create( + name=slug, + slug=slug, + enabled_modules=list(modules), + anonymous_requests_enabled=anonymous, + ) + + +CORE_PLUS = ["public_inventory", "request_workflow", "staff_admin", "scanner", + "evidence_uploads", "qr_management"] + + +# --------------------------------------------------------------------------- model + + +def test_saving_with_membership_forces_account_less_requests_off(): + space = _space("ra-model", modules=[*CORE_PLUS, "membership"], anonymous=True) + + space.refresh_from_db() + assert space.anonymous_requests_enabled is False + assert effective_policy(space) == MEMBERS + + +def test_a_partial_save_cannot_leave_the_impossible_pair_on_the_row(): + """`save(update_fields=[...])` that does not name the flag must still persist it.""" + space = _space("ra-partial", modules=CORE_PLUS, anonymous=True) + assert space.anonymous_requests_enabled is True + + space.enabled_modules = [*CORE_PLUS, "membership"] + space.save(update_fields=["enabled_modules"]) + + space.refresh_from_db() + assert space.anonymous_requests_enabled is False + + +def test_membership_off_leaves_account_less_requests_alone(): + space = _space("ra-open", modules=CORE_PLUS, anonymous=True) + + space.refresh_from_db() + assert space.anonymous_requests_enabled is True + assert effective_policy(space) == ANYONE + + +def test_policy_without_membership_and_without_the_flag_is_accounts(): + space = _space("ra-accounts", modules=CORE_PLUS) + assert effective_policy(space) == ACCOUNTS + + +# ------------------------------------------------------------------- module install + + +def test_installing_membership_closes_account_less_requests(): + space = _space("ra-install", modules=CORE_PLUS, anonymous=True) + + install_module(space, "membership") + + space.refresh_from_db() + assert space.anonymous_requests_enabled is False + + +def test_uninstalling_membership_does_not_reopen_account_less_requests(): + """Forcing off is one-way. Re-opening an unauthenticated write surface is an + explicit operator act, never a side effect of removing an unrelated module.""" + space = _space("ra-uninstall", modules=[*CORE_PLUS, "membership", "member_accounts"]) + + uninstall_module(space, "membership") + + space.refresh_from_db() + assert space.anonymous_requests_enabled is False + assert effective_policy(space) == ACCOUNTS + + +# ------------------------------------------------------------------------- service + + +def test_set_anonymous_requests_refuses_while_membership_is_installed(): + space = _space("ra-refuse", modules=[*CORE_PLUS, "membership"]) + + with pytest.raises(RequestAccessConflict): + set_anonymous_requests(space, True) + + space.refresh_from_db() + assert space.anonymous_requests_enabled is False + + +def test_set_anonymous_requests_audits_the_policy_change(): + space = _space("ra-audit", modules=CORE_PLUS) + + resulting = set_anonymous_requests(space, True) + + assert resulting == ANYONE + entry = AuditLog.objects.filter(action="makerspace.request_access_changed").latest("id") + assert entry.meta["before"] == ACCOUNTS + assert entry.meta["after"] == ANYONE + + +def test_setting_the_same_value_twice_writes_no_second_audit_row(): + space = _space("ra-idempotent", modules=CORE_PLUS) + set_anonymous_requests(space, True) + before = AuditLog.objects.filter(action="makerspace.request_access_changed").count() + + set_anonymous_requests(space, True) + + assert AuditLog.objects.filter(action="makerspace.request_access_changed").count() == before + + +# ------------------------------------------------------------------------- command + + +def test_command_reports_the_policy_the_module_state_actually_produces(): + space = _space("ra-cmd-accounts", modules=CORE_PLUS) + out = StringIO() + + call_command("set_request_access", "--makerspace", space.slug, "--mode", MEMBERS, stdout=out) + + text = out.getvalue() + # `members` was asked for, but without the membership module the honest answer is + # `accounts`, and the operator must be told rather than left believing otherwise. + assert "signed-in account" in text + assert "You asked for 'members'" in text + + +def test_command_refuses_anyone_when_membership_is_installed(): + space = _space("ra-cmd-conflict", modules=[*CORE_PLUS, "membership"]) + + with pytest.raises(CommandError): + call_command("set_request_access", "--makerspace", space.slug, "--mode", ANYONE) + + space.refresh_from_db() + assert space.anonymous_requests_enabled is False + + +def test_list_modules_json_reports_installed_keys_and_request_access(): + space = _space("ra-json", modules=CORE_PLUS, anonymous=True) + out = StringIO() + + call_command("list_modules", "--makerspace", space.slug, "--json", stdout=out) + + payload = json.loads(out.getvalue()) + assert payload["makerspace"] == space.slug + assert payload["request_access"] == ANYONE + assert "request_workflow" in payload["installed"] + + +# ---------------------------------------------------------------------------- view + + +def test_an_anonymous_submission_is_refused_when_membership_is_installed(): + """The hole this closes: the row carries BOTH, written behind the model rule.""" + space = _space("ra-view", modules=[*CORE_PLUS, "membership"]) + # Straight to the column, bypassing save() exactly as raw SQL or an old restore would. + Makerspace.objects.filter(pk=space.pk).update(anonymous_requests_enabled=True) + product = InventoryProduct.objects.create( + makerspace=space, name="Multimeter", total_quantity=2, available_quantity=2, is_public=True, + ) + + response = APIClient().post( + reverse("hardware_requests:request-submit", args=[space.slug]), + { + "contact_name": "Stranger", + "contact_email": "stranger@example.test", + "items": [{"product_id": product.id, "quantity": 1}], + }, + format="json", + HTTP_IDEMPOTENCY_KEY="ra-view-key", + ) + + assert response.status_code == 401 diff --git a/backend/tests/operations/test_anonymous_requester_isolation.py b/backend/tests/operations/test_anonymous_requester_isolation.py new file mode 100644 index 00000000..7716c683 --- /dev/null +++ b/backend/tests/operations/test_anonymous_requester_isolation.py @@ -0,0 +1,227 @@ +"""Account-less requests must not collapse into one fictional person downstream. + +Every anonymous submission in a makerspace points at the SAME +`Makerspace.anonymous_requester` principal, because that is what gives the request a +requester FK without inventing an account. Any report that groups by `requester_id` then +folds a hundred unrelated strangers into a single row: one "repeat offender", one "top +borrower", one restrictable user whose restriction would hit every future account-less +requester at once. + +The principal is excluded from every per-PERSON ranking, and — because losing the data +entirely would be worse than naming a fake person — the damage and loss it was carrying is +reported as a separate total instead. +""" + +import pytest +from datetime import timedelta + +from django.utils import timezone + +from apps.accounts.models import User +from apps.hardware_requests.models import ( + HardwareRequest, + HardwareRequestItem, + RequesterAccountability, +) +from apps.inventory.models import InventoryProduct +from apps.makerspaces.anonymous_requesters import ( + anonymous_requester_ids, + get_or_create_anonymous_requester, +) +from apps.makerspaces.models import Makerspace +from apps.operations import reports +from apps.operations.accountability import accountability_data + +pytestmark = pytest.mark.django_db + + +def _space(slug): + return Makerspace.objects.create(name=slug, slug=slug) + + +def _member(username): + return User.objects.create_user( + username=username, email=f"{username}@example.test", display_name=username, + ) + + +def _product(space): + return InventoryProduct.objects.create( + makerspace=space, name="Oscilloscope", total_quantity=50, available_quantity=50, + ) + + +def _issued_request(space, product, requester, *, quantity=1, name="", email=""): + request = HardwareRequest.objects.create( + makerspace=space, + requester=requester, + requester_username="" if name else requester.username, + requester_name=name, + requester_contact_email=email, + requester_contact_verified=not name, + status=HardwareRequest.Status.ISSUED, + issued_at=timezone.now(), + ) + HardwareRequestItem.objects.create( + request=request, + product=product, + requested_quantity=quantity, + accepted_quantity=quantity, + issued_quantity=quantity, + ) + return request + + +def _recorder(): + """The staffer who recorded the incident. + + Irrelevant to what these tests assert -- they are about the *requester* side -- but + `created_by` is a non-null PROTECT FK, so every row needs one. Reused rather than + created per call: a test records several incidents, and `username` is unique. + """ + user, _ = User.objects.get_or_create( + username="iso-recorder", + defaults={ + "email": "iso-recorder@example.test", + "display_name": "iso-recorder", + }, + ) + return user + + +def _accountability(space, requester, issue_type, quantity=1): + """An incident recorded against `requester`. + + `request` and `request_item` are both non-null on the model -- an incident is always + against one specific issued line -- so the helper creates the loan it is recording + rather than making every caller build one it does not otherwise care about. + """ + request = _issued_request(space, _product(space), requester, quantity=quantity) + return RequesterAccountability.objects.create( + makerspace=space, + requester=requester, + request=request, + request_item=HardwareRequestItem.objects.get(request=request), + issue_type=issue_type, + quantity=quantity, + created_by=_recorder(), + ) + + +def test_the_shared_principal_never_appears_as_a_top_borrower(): + space = _space("iso-top-borrowers") + product = _product(space) + principal = get_or_create_anonymous_requester(space) + member = _member("iso-real-borrower") + + _issued_request(space, product, member, quantity=1) + # Three strangers, one principal — this is the collapse being prevented. Left in, the + # principal would out-rank the real borrower three to one. + for index in range(3): + _issued_request( + space, product, principal, quantity=5, + name=f"Stranger {index}", email=f"stranger{index}@example.test", + ) + + rows = reports._top_borrowers(space.id, aggregate=False) + + assert rows[0] == ["holder", "requests", "items_borrowed"] + holders = [row[0] for row in rows[1:]] + assert holders == ["iso-real-borrower"] + assert principal.username not in holders + + +def test_the_export_header_is_unchanged_by_the_exclusion(): + """The report registry pins these three columns; excluding rows must not touch them.""" + space = _space("iso-header") + product = _product(space) + principal = get_or_create_anonymous_requester(space) + _issued_request(space, product, principal, name="Stranger", email="s@example.test") + + rows = reports._top_borrowers(space.id, aggregate=False) + + assert rows[0] == ["holder", "requests", "items_borrowed"] + assert rows[1:] == [] + + +def test_the_shared_principal_never_appears_as_a_repeat_offender(): + space = _space("iso-offenders") + principal = get_or_create_anonymous_requester(space) + member = _member("iso-real-offender") + _accountability(space, member, RequesterAccountability.IssueType.DAMAGED) + for _ in range(4): + _accountability(space, principal, RequesterAccountability.IssueType.MISSING, quantity=2) + + data = accountability_data(space.id) + + assert [row["requester_id"] for row in data["repeat_offenders"]] == [member.id] + + +def test_the_excluded_damage_and_loss_is_still_reported_as_a_total(): + """Excluding the principal must not silently delete accountability history.""" + space = _space("iso-aggregate") + principal = get_or_create_anonymous_requester(space) + _accountability(space, principal, RequesterAccountability.IssueType.DAMAGED, quantity=2) + _accountability(space, principal, RequesterAccountability.IssueType.MISSING, quantity=3) + + data = accountability_data(space.id) + + assert data["anonymous_accountability"] == { + "damaged": 1, "missing": 1, "total_issues": 2, "total_quantity": 5, + } + + +def test_the_aggregate_is_zero_shaped_when_there_are_no_account_less_incidents(): + space = _space("iso-aggregate-empty") + _accountability(space, _member("iso-only-member"), RequesterAccountability.IssueType.DAMAGED) + + data = accountability_data(space.id) + + assert data["anonymous_accountability"] == { + "damaged": 0, "missing": 0, "total_issues": 0, "total_quantity": 0, + } + + +def test_an_overdue_account_less_loan_shows_the_contact_not_the_internal_username(): + """The principal's username is `member_`, which tells staff nothing.""" + space = _space("iso-overdue") + product = _product(space) + principal = get_or_create_anonymous_requester(space) + request = _issued_request( + space, product, principal, name="Ada Lovelace", email="ada@example.test", + ) + request.return_due_at = timezone.now() - timedelta(days=2) + request.save(update_fields=["return_due_at"]) + + data = accountability_data(space.id) + + overdue = [row for row in data["overdue"] if row["reference_id"] == request.id] + assert overdue, data["overdue"] + assert overdue[0]["requester_username"] == "Ada Lovelace" + assert not overdue[0]["requester_username"].startswith("member_") + + +def test_a_restricted_principal_is_never_listed_as_a_restricted_person(): + """Restrict/restore already refuse the principal; this is the read-side backstop for + a row written before that guard, or by a direct database edit.""" + space = _space("iso-restricted") + principal = get_or_create_anonymous_requester(space) + _accountability(space, principal, RequesterAccountability.IssueType.MISSING) + User.objects.filter(pk=principal.pk).update( + access_status=User.AccessStatus.RESTRICTED, restriction_reason="edited directly", + ) + + data = accountability_data(space.id) + + assert [row["requester_id"] for row in data["restrictions"]] == [] + + +def test_anonymous_requester_ids_is_scoped_and_global(): + one, two = _space("iso-scope-one"), _space("iso-scope-two") + first = get_or_create_anonymous_requester(one) + second = get_or_create_anonymous_requester(two) + + assert anonymous_requester_ids([one.id]) == {first.id} + assert anonymous_requester_ids() >= {first.id, second.id} + # A makerspace that has never taken an account-less request has no principal at all. + assert anonymous_requester_ids([_space("iso-scope-three").id]) == set() diff --git a/backend/tests/test_anonymous_request_submit_b2.py b/backend/tests/test_anonymous_request_submit_b2.py index a38edafc..c29a89a6 100644 --- a/backend/tests/test_anonymous_request_submit_b2.py +++ b/backend/tests/test_anonymous_request_submit_b2.py @@ -260,3 +260,26 @@ def test_outstanding_anonymous_ceiling_returns_typed_error(settings): assert second.status_code == 429 assert second.data["code"] == "anonymous_request_outstanding_limit" assert HardwareRequest.objects.filter(makerspace=space).count() == 1 + + +def test_the_refusal_path_is_throttled_not_just_the_accepted_one(): + """An unopted-in space must not serve an UNBOUNDED 401. + + The throttles used to be selected inside `post()`, which runs *after* the + `anonymous_requests_allowed` refusal -- so every makerspace that had not opted in + (the default) answered unauthenticated POSTs forever, each one still paying for a + makerspace lookup. They are declared in `throttle_classes` now, so DRF charges the + IP budget in `initial()`, before the handler and before that refusal. + """ + disabled = _space("anonymous-refusal-throttled", anonymous=False) + payload = _payload(_product(disabled)) + + statuses = [ + _submit(disabled, payload, f"refusal-{index}", ip="203.0.113.77").status_code + for index in range(3) + ] + + # 2/min burst: the first two are refused for being account-less, the third for + # exhausting the budget. Before the fix this was [401, 401, 401]. + assert statuses[:2] == [401, 401] + assert statuses[2] == 429 diff --git a/backend/tests/test_hardware_admin_actions.py b/backend/tests/test_hardware_admin_actions.py index a939fc45..5bd152cb 100644 --- a/backend/tests/test_hardware_admin_actions.py +++ b/backend/tests/test_hardware_admin_actions.py @@ -83,32 +83,99 @@ def action_payload(action, hardware_request, **extra): } -def test_accept_action_moves_pending_request_to_accepted(): +def review_url(hardware_request): + return reverse( + "admin:hardware_requests_hardwarerequest_review", args=[hardware_request.pk] + ) + + +def test_the_bulk_accept_and_reject_actions_no_longer_exist(): + """Accepting reserves stock and rejecting closes a person's ask. Neither is a + checkbox-column decision any more -- both moved to the one-by-one review page.""" + superadmin = make_superadmin("hardware-no-bulk-superadmin") + hardware_request = make_hardware_request() + + for action in ("accept_selected", "reject_selected"): + response = admin_client(superadmin).post( + changelist_url(), action_payload(action, hardware_request), follow=True + ) + hardware_request.refresh_from_db() + assert hardware_request.status == HardwareRequest.Status.PENDING_APPROVAL, action + messages = [str(message) for message in get_messages(response.wsgi_request)] + assert "No action selected." in messages, action + + +def test_the_review_page_accepts_one_request(): superadmin = make_superadmin() hardware_request = make_hardware_request() + response = admin_client(superadmin).post(review_url(hardware_request), {"accept": "1"}) + + assert response.status_code == 302 + hardware_request.refresh_from_db() + assert hardware_request.status == HardwareRequest.Status.ACCEPTED + + +def test_the_review_page_honours_a_lowered_accepted_quantity(): + superadmin = make_superadmin("hardware-partial-superadmin") + hardware_request = make_hardware_request() + item = hardware_request.items.get() + item.requested_quantity = 3 + item.save(update_fields=["requested_quantity"]) + response = admin_client(superadmin).post( - changelist_url(), - action_payload("accept_selected", hardware_request), + review_url(hardware_request), + {"accept": "1", f"accepted_quantity_{item.pk}": "2"}, ) assert response.status_code == 302 + item.refresh_from_db() + assert item.accepted_quantity == 2 + + +def test_the_review_page_refuses_a_quantity_above_what_was_requested(): + superadmin = make_superadmin("hardware-overrequest-superadmin") + hardware_request = make_hardware_request() + item = hardware_request.items.get() + + response = admin_client(superadmin).post( + review_url(hardware_request), + {"accept": "1", f"accepted_quantity_{item.pk}": "99"}, + follow=True, + ) + hardware_request.refresh_from_db() - assert hardware_request.status == HardwareRequest.Status.ACCEPTED + assert hardware_request.status == HardwareRequest.Status.PENDING_APPROVAL + messages = [str(message) for message in get_messages(response.wsgi_request)] + assert any("must be between 0 and" in message for message in messages) + + +def test_the_review_page_refuses_a_non_numeric_quantity(): + """Parsed here rather than handed to the workflow, so a malformed field is an error + on the page instead of a 500 inside the service.""" + superadmin = make_superadmin("hardware-nan-superadmin") + hardware_request = make_hardware_request() + item = hardware_request.items.get() + + response = admin_client(superadmin).post( + review_url(hardware_request), + {"accept": "1", f"accepted_quantity_{item.pk}": "two"}, + follow=True, + ) + hardware_request.refresh_from_db() + assert hardware_request.status == HardwareRequest.Status.PENDING_APPROVAL + messages = [str(message) for message in get_messages(response.wsgi_request)] + assert any("whole number" in message for message in messages) -def test_reject_action_with_reason_moves_pending_request_to_rejected(): + +def test_the_review_page_rejects_with_a_reason(): superadmin = make_superadmin("hardware-reject-superadmin") hardware_request = make_hardware_request() response = admin_client(superadmin).post( - changelist_url(), - action_payload( - "reject_selected", - hardware_request, - apply="1", - reason="Unavailable this week.", - ), + review_url(hardware_request), + {"reject": "1", "reason": "Unavailable this week."}, ) assert response.status_code == 302 @@ -117,19 +184,13 @@ def test_reject_action_with_reason_moves_pending_request_to_rejected(): assert hardware_request.rejection_reason == "Unavailable this week." -def test_reject_action_with_empty_reason_does_not_change_status_and_reports_error(): +def test_the_review_page_refuses_to_reject_without_a_reason(): superadmin = make_superadmin("hardware-empty-reject-superadmin") hardware_request = make_hardware_request() - client = admin_client(superadmin) - - response = client.post( - changelist_url(), - action_payload( - "reject_selected", - hardware_request, - apply="1", - reason=" ", - ), + + response = admin_client(superadmin).post( + review_url(hardware_request), + {"reject": "1", "reason": " "}, follow=True, ) @@ -139,23 +200,14 @@ def test_reject_action_with_empty_reason_does_not_change_status_and_reports_erro assert "Rejection reason is required." in messages -def test_hardware_request_admin_blocks_direct_add_and_delete(): - """Regression: requests are workflow-driven; the admin must not expose - direct add (broken readonly form) or delete (bypasses reservation/audit).""" - superadmin = make_superadmin("hardware-add-delete-superadmin") - # has_*_permission=False raises PermissionDenied (rendered as 403); the test - # client must not re-raise it. - client = Client(raise_request_exception=False) - client.force_login(superadmin) +def test_the_review_page_is_closed_to_a_non_superadmin(): + """`admin_site.admin_view` plus the model's superuser gate. Without both, this URL + would be the one state-changing surface that skipped them.""" + staffer = make_user("hardware-review-staffer", is_staff=True) + hardware_request = make_hardware_request() - add_url = reverse("admin:hardware_requests_hardwarerequest_add") - add_response = client.get(add_url) - assert add_response.status_code == 403 + response = admin_client(staffer).get(review_url(hardware_request)) - hardware_request = make_hardware_request() - delete_url = reverse( - "admin:hardware_requests_hardwarerequest_delete", - args=[hardware_request.pk], - ) - delete_response = client.get(delete_url) - assert delete_response.status_code == 403 + assert response.status_code in (302, 403, 404) + hardware_request.refresh_from_db() + assert hardware_request.status == HardwareRequest.Status.PENDING_APPROVAL diff --git a/backend/tests/test_notification_destinations.py b/backend/tests/test_notification_destinations.py index 4f858089..e21c1617 100644 --- a/backend/tests/test_notification_destinations.py +++ b/backend/tests/test_notification_destinations.py @@ -333,19 +333,22 @@ def test_a_telegram_room_shares_the_makerspace_bot(): assert telegram.resolve_bot_token(space) == "space-token" -def test_inbound_callbacks_do_not_depend_on_which_room_sent_the_message(): - """G6: the reason one bot per makerspace is enough. - - An accept/reject button posts back to a single registered webhook authenticated by - one deployment-wide secret. Routing resolves the ACTOR from `from.id` and the request - from the callback data — the chat the button was pressed in is never consulted — so - adding rooms cannot strand a callback. If this ever starts reading a chat id, per-room - Telegram destinations need per-bot webhook secrets and inbound routing first. +def test_no_room_can_turn_a_telegram_alert_into_an_action_surface(): + """G6, restated for a one-way channel. + + This used to pin inbound callback routing: an accept/reject button posted back to a + single registered webhook, and routing never consulted the chat it came from, so + adding rooms could not strand a callback. **The buttons are gone.** What has to hold + now is simpler and stricter — no room, however configured, gets an inline keyboard, + because an inline keyboard IS the action surface that was removed. If a button is + ever reintroduced, per-room destinations need per-bot webhook secrets and inbound + routing before it ships. """ - from apps.integrations.views import _parse_callback + import inspect - assert _parse_callback("accept:42") == ("accept", 42, "") - assert _parse_callback("reject:42:out of stock") == ("reject", 42, "out of stock") + from apps.integrations import telegram + + assert "reply_markup" not in inspect.signature(telegram.send_message).parameters @override_settings(TELEGRAM_BOT_TOKEN="deployment-token") diff --git a/backend/tests/test_notification_dispatch.py b/backend/tests/test_notification_dispatch.py index a20f4a60..d6db7400 100644 --- a/backend/tests/test_notification_dispatch.py +++ b/backend/tests/test_notification_dispatch.py @@ -160,18 +160,20 @@ def test_telegram_payload_is_durable_and_passed_to_sender(monkeypatch): monkeypatch.setattr(limits, "is_self_host", lambda: True) sender = Mock(return_value=True) monkeypatch.setattr("apps.integrations.telegram.send_message", sender) - markup = {"inline_keyboard": [[{"text": "Open", "url": "https://safe.test"}]]} + # An arbitrary payload: the `payload` column carries delivery-time context (today, + # the alert's `scope`, which push resolves its recipients from), and the property + # under test is that it survives to the log row intact. + payload = {"scope": {"kind": "machine", "id": 7}} - log = dispatch(space, "telegram", payload={"reply_markup": markup}, sync=True) + log = dispatch(space, "telegram", payload=payload, sync=True) log.refresh_from_db() - assert log.payload == {"reply_markup": markup} + assert log.payload == payload assert log.status == NotificationDeliveryStatus.SENT # destination=None is the legacy makerspace-column path: this space has no rooms, so - # the chat id still comes off the makerspace exactly as it did. - sender.assert_called_once_with( - space, "Booking confirmed.", reply_markup=markup, destination=None - ) + # the chat id still comes off the makerspace exactly as it did. No `reply_markup`: + # the Telegram sender has no such parameter any more. + sender.assert_called_once_with(space, "Booking confirmed.", destination=None) def test_async_dispatch_enqueues_after_commit( diff --git a/backend/tests/test_notification_fanout.py b/backend/tests/test_notification_fanout.py index 89c372b7..b590f3d5 100644 --- a/backend/tests/test_notification_fanout.py +++ b/backend/tests/test_notification_fanout.py @@ -182,7 +182,7 @@ def test_raising_build_and_dispatch_never_escape(monkeypatch): } -def test_hardware_submitted_adapter_calls_one_fanout_with_original_buttons(monkeypatch): +def test_hardware_submitted_adapter_calls_one_fanout_and_carries_no_buttons(monkeypatch): space = make_space("fanout-hardware-adapter") requester = User.objects.create_user(username="fanout-hardware-requester") request = HardwareRequest.objects.create( @@ -206,13 +206,9 @@ def capture(makerspace, **kwargs): hardware_notifications.notify_request_submitted(request) assert len(calls) == 1 assert calls[0][1]["event"] == "submitted" - assert calls[0][2].telegram_reply_markup == { - "inline_keyboard": [[ - {"text": "Accept", "callback_data": f"accept:{request.pk}"}, - { - "text": "Reject", - "callback_data": f"reject:{request.pk}:Rejected from Telegram.", - }, - ]] - } + # No inline keyboard any more: chat is a notification channel, not a decision + # surface, so the adapter's whole telegram_reply_markup path was removed with the + # callback route it fed. + assert not hasattr(calls[0][2], "telegram_reply_markup") + assert "Review and decide in the staff console." in calls[0][2].text diff --git a/backend/tests/test_telegram_integration.py b/backend/tests/test_telegram_integration.py index e2b51adf..09eebe91 100644 --- a/backend/tests/test_telegram_integration.py +++ b/backend/tests/test_telegram_integration.py @@ -61,12 +61,21 @@ def test_telegram_webhook_throttles_rapid_requests(settings, monkeypatch): HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN=WEBHOOK_SECRET, ) - assert first.status_code == 403 + # 200 now, not 403: the callback is acknowledged and discarded rather than + # rejected during actor resolution, which no longer happens. + assert first.status_code == 200 assert second.status_code == 429 @override_settings(TELEGRAM_WEBHOOK_SECRET=WEBHOOK_SECRET) -def test_telegram_accept_callback_routes_through_workflow(monkeypatch): +def test_an_accept_callback_no_longer_changes_request_state(monkeypatch): + """Chat is a notification channel, not a decision surface. + + This replaces `test_telegram_accept_callback_routes_through_workflow`. The actor + resolution and RBAC checks it used to assert went with the route: there is nothing + left to authorize, so the property worth pinning is that a well-formed, correctly + secreted accept callback from a genuinely authorized admin still moves nothing. + """ makerspace = make_space("telegram") admin = make_member("telegram-admin", makerspace) admin.telegram_user_id = "42" @@ -89,13 +98,18 @@ def test_telegram_accept_callback_routes_through_workflow(monkeypatch): HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN=WEBHOOK_SECRET, ) + # 200, not 403: an already-registered webhook must be acknowledged rather than + # retried for hours by Telegram. assert response.status_code == 200 + assert response.data["detail"] == "Ignored." hardware_request.refresh_from_db() - assert hardware_request.status == HardwareRequest.Status.ACCEPTED + assert hardware_request.status == HardwareRequest.Status.PENDING_APPROVAL @override_settings(TELEGRAM_WEBHOOK_SECRET=WEBHOOK_SECRET) -def test_unlinked_telegram_actor_is_denied(): +def test_an_unlinked_telegram_actor_is_acknowledged_and_ignored(): + """Previously 403 via actor resolution. There is no actor to resolve now, and an + unauthenticated stranger who guessed the secret still cannot make anything happen.""" response = authenticated_client( User.objects.create_user( username="placeholder", @@ -109,7 +123,8 @@ def test_unlinked_telegram_actor_is_denied(): HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN=WEBHOOK_SECRET, ) - assert response.status_code == 403 + assert response.status_code == 200 + assert response.data["detail"] == "Ignored." @override_settings(TELEGRAM_WEBHOOK_SECRET=WEBHOOK_SECRET) @@ -131,30 +146,6 @@ def test_telegram_webhook_rejects_missing_or_wrong_secret(): assert response.status_code == 403 -@override_settings(TELEGRAM_WEBHOOK_SECRET=WEBHOOK_SECRET) -def test_suspended_telegram_actor_cannot_act(): - makerspace = make_space("telegram-suspended") - admin = make_member("telegram-suspended-admin", makerspace) - admin.telegram_user_id = "77" - admin.access_status = User.AccessStatus.SUSPENDED - admin.save(update_fields=["telegram_user_id", "access_status"]) - product = make_product(makerspace) - hardware_request = make_accepted_request(makerspace, product, 1) - hardware_request.status = HardwareRequest.Status.PENDING_APPROVAL - hardware_request.save(update_fields=["status"]) - - response = authenticated_client(admin).post( - WEBHOOK_URL, - {"callback_query": {"from": {"id": 77}, "data": f"accept:{hardware_request.id}"}}, - format="json", - HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN=WEBHOOK_SECRET, - ) - - assert response.status_code == 403 - hardware_request.refresh_from_db() - assert hardware_request.status == HardwareRequest.Status.PENDING_APPROVAL - - def test_suspended_user_cannot_send_telegram_test_alert(): makerspace = make_space("telegram-test-alert") admin = make_member("telegram-alert-admin", makerspace) @@ -276,7 +267,10 @@ def test_submitted_request_telegram_alert_includes_contact_and_items( assert "Bench Meter: 2" in text assert "Logic Analyzer: 3" in text assert "parse_mode" not in sent.call_args.kwargs - assert sent.call_args.kwargs["reply_markup"]["inline_keyboard"] + # No inline keyboard: an accept/reject button IS an action surface, and chat is + # not one any more. The alert now points staff at the console instead. + assert "reply_markup" not in sent.call_args.kwargs + assert "Review and decide in the staff console." in text def test_submitted_request_telegram_delivery_error_is_swallowed( @@ -343,7 +337,10 @@ def test_submitted_request_telegram_message_stays_within_limit( text = sent.call_args.args[1] assert len(text) <= 4096 - assert sent.call_args.kwargs["reply_markup"]["inline_keyboard"] + # No inline keyboard: an accept/reject button IS an action surface, and chat is + # not one any more. The alert now points staff at the console instead. + assert "reply_markup" not in sent.call_args.kwargs + assert "Review and decide in the staff console." in text @override_settings(TELEGRAM_WEBHOOK_SECRET="") diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fb70b422..b89381eb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -47,17 +47,30 @@ services: # Migrations run from the mounted tree, so a migration you just wrote applies # without rebuilding the image. + # These three inherit `DEBUG: ${DEBUG:-False}` from the base &backend-env anchor -- only + # `backend` above overrides DEBUG to True. Since the trusted-proxy guard landed in settings.py + # ("TRUSTED_PROXY_COUNT must be explicitly set when DEBUG is False"), that made every one of + # them crash on boot and took `dev-docker.sh up --build` with them. Declared here rather than + # defaulted in the base anchor on purpose: the guard exists so a PRODUCTION topology has to be + # stated explicitly, and a base-level default would silently satisfy it. None of these three + # serves HTTP, and dev is direct access, so 0 is the honest value. migrate: + environment: + TRUSTED_PROXY_COUNT: "0" volumes: - ./backend:/app # Celery has no autoreload: restart these two after changing task code # (`./scripts/dev-docker.sh restart worker beat`). worker: + environment: + TRUSTED_PROXY_COUNT: "0" volumes: - ./backend:/app beat: + environment: + TRUSTED_PROXY_COUNT: "0" volumes: - ./backend:/app diff --git a/docs/DEV-WORKFLOW.md b/docs/DEV-WORKFLOW.md index a71dcff8..3df07531 100644 --- a/docs/DEV-WORKFLOW.md +++ b/docs/DEV-WORKFLOW.md @@ -152,10 +152,20 @@ The default path needs nothing installed on the host but Docker. Migrations run # After changing package.json, recreate the node_modules volume: ./scripts/dev-docker.sh up -d --build -V frontend -# Tests -./scripts/dev-docker.sh exec backend pytest +# Tests. The DATABASE_URL override is REQUIRED: the backend container runs as the +# least-privilege `spaceworks_app` role, which has no CREATEDB, so pytest cannot build +# its test database as itself (every django_db test errors in setup without this). +./scripts/dev-docker.sh exec -e DATABASE_URL=postgres://makerspace:makerspace@db:5432/makerspace_manager \ + -T backend pytest ``` +`tests/backup` and `tests/tenant_migration` are **Docker-only in practice**: they need a PostgreSQL +client whose major equals the server's (16), which `postgres_client.client_binary` looks for under +`/usr/lib/postgresql/{major}/bin` or `/usr/pgsql-{major}/bin`. Neither path exists on Arch, so on the +host they all refuse with `PostgresClientUnavailable` — the environment, not a regression. The backend +image installs client 16 *and* 17 on purpose (`pg_dump` must be >= every supported source server; +`pg_restore` must be <= the target, because 17+ emits a `transaction_timeout` GUC that 16 rejects). + Host fallback (faster `pytest` / one-off `manage.py`; needs `backend/.venv` + `npm install`): ```bash diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index ba666b1a..48d0bb0d 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -1053,6 +1053,77 @@ from `index.css` and `tailwind.config.ts`. **Console parity principle.** Every backend lifecycle capability reachable in the Django `/control/` admin must have a React staff-console surface — a capability with no console surface is a latent dead/broken feature for normal staff. New workflow actions ship their staff UI in the same batch. + +**Who may submit a borrow request is DERIVED, and `membership` + account-less requests is an impossible +pair.** `apps/makerspaces/request_access.py` is the only answer to the question. Three states fall out of +one column and one module: `membership` on → **members**; both off → **accounts** (any active signed-in +account); `anonymous_requests_enabled` on with `membership` off → **anyone**. The fourth combination must +not exist, because `RequestSubmitView` takes its anonymous branch *before* any membership guard runs, so a +row carrying both would walk a stranger straight past the membership requirement the operator had just +switched on. It is enforced at three depths and all three are load-bearing: `Makerspace.save()` reconciles +the pair so **no writer** can persist it (module install/uninstall, `apply_profile`, the `/control/` +capability matrix, `setup_instance`, `seed_demo`, a bare `obj.save()`) — and it appends the field to +`update_fields` so a partial save cannot leave the row inconsistent; `set_anonymous_requests` takes the row +lock and **refuses** an explicit request to open account-less submission while `membership` is installed +(the module path *forces*, the operator path *refuses* — the operator was asking about this flag, so +silently ignoring them would be the wrong answer); and `anonymous_requests_allowed` re-derives at request +time so a row from raw SQL or an old restore still fails closed. Migration +`makerspaces/0067` closed the pair on existing rows and is deliberately **not reversible**. Turning +`membership` off never re-opens account-less requests — opening an unauthenticated write surface is an +explicit act, never a side effect. + +**A core module must work with every optional module uninstalled.** Two halves. The *declared* half is +checked at import time: `module_registry._validate_registry` refuses a registry where an `is_core` module +names a non-core key in `requires_modules`. The *undeclared* half — core code reaching for a row an optional +module owns — has no sound static check in this codebase, because an app label is not the unit of module +ownership (`hardware_requests` owns core `request_workflow` **and** optional `guest_handover`; `admin_api` +owns core `staff_admin` and many optional surfaces), so no per-file rule can tell a legitimate +optional-module gate from a genuine violation. It is covered behaviourally instead, by +`tests/makerspaces/test_core_module_independence.py`: the core loan spine (catalogue → submit → staff queue +→ accept → public status) is driven over HTTP on a core-only makerspace and on `core + every optional +module except M`, for every optional `M`, with the **test identity built to match each configuration** — +with `membership` installed the spine legitimately needs an active member and an open presence session, and +a test that always used a plain account would be refused before reaching the code under test and would +prove nothing. This is the regression `9e496997` shipped: core `request_workflow` called +`require_active_member_presence` unconditionally and the default `recommended` profile ships no +`membership`, so a fresh self-host install 403'd every public borrow request. Do not weaken the claim in +that file's docstring; it says what it proves and what it does not. + +**Telegram is an OUTBOUND channel. Chat is not a decision surface.** The inline accept/reject keyboard and +the callback route that served it are gone: `LifecyclePayload` has no `telegram_reply_markup`, +`telegram.send_message` has no `reply_markup` parameter, and `TelegramWebhookView` acknowledges and +discards every callback. The webhook **route is kept on purpose** — a deployment that already ran +`setWebhook` has the URL registered, we cannot call `deleteWebhook` for them, and Telegram retries a +non-2xx for hours, so a 200 is the graceful retirement and a 404 would be a permanent error loop in +someone else's infrastructure. The secret check stays even though nothing acts behind it, because an +endpoint that had quietly stopped authenticating is exactly what would turn a reintroduced callback into a +vulnerability. The one-bot-per-makerspace rule (D16) survives on its **outbound** half only — one sender +identity across a tenant's rooms, one token secret — since its original inbound justification expired with +the buttons. Reintroducing a button means per-bot webhook secrets and inbound routing first. + +**Accepting and rejecting a borrow request are one-at-a-time acts.** The `/control/` bulk `accept_selected` +and `reject_selected` actions were removed; `admin_request_review.RequestReviewAdminMixin` serves a +per-request review page instead, showing requester identity, contact-verification state and per-item +accepted quantities. Accepting reserves stock and rejecting closes a person's ask, and neither is a +judgement made about twenty rows from a checkbox column. The mutations still go through +`request_workflow`, so the state machine, audit entry and notification fan-out are unchanged — only the +entry point moved. The review URL must keep `admin_site.admin_view`, must load through +`self.get_queryset(request)` (the superadmin queryset excludes hard-hidden makerspaces), must re-check +`has_change_permission`, and must parse quantities as ints before calling the workflow. + +**The shared account-less requester principal is not a person, and no per-PERSON aggregate may rank it.** +Every anonymous submission in a makerspace points at one `Makerspace.anonymous_requester`, so grouping by +`requester_id` folds every unrelated stranger into a single fictional human. `_repeat_offenders`, +`_top_borrowers`, `globally_ranked_borrowers` and `_restricted_requesters` exclude +`anonymous_requesters.anonymous_requester_ids(...)` **before** any limit or slice, so the principal cannot +displace a real borrower from the top N. The excluded damage and loss is not dropped: `accountability_data` +reports it as an additive `anonymous_accountability` total, because an accountability panel that silently +lost every account-less incident would be worse than one naming a fake person. Per-request rows keep +showing, but route their `requester_username` through `display.requester_label` so an overdue account-less +loan shows the contact the borrower gave rather than the principal's internal `member_`. Restricting +the principal is refused at the write side by +`accounts.principal_guards.refuse_anonymous_requester_access_mutation` — it would restrict every future +account-less requester at once — and the read-side exclusion is the backstop for rows predating that guard. ## Handover roles and the retired Guest Admin **Guest Admin is no longer a built-in role** (migration `makerspaces/0052`); handover staff get a **custom diff --git a/docs/LANDING-PAGE-FEATURES.md b/docs/LANDING-PAGE-FEATURES.md index 364142f6..e76c0795 100644 --- a/docs/LANDING-PAGE-FEATURES.md +++ b/docs/LANDING-PAGE-FEATURES.md @@ -293,7 +293,7 @@ supported installation without them. | Membership | Join requests, waivers, referrals, profiles, directory and activity | People can still borrow, but enrolment and community features disappear | | Notifications | In-app inbox and unread state | No in-app alerts; separately installed outbound channels can still send | | Email | Makerspace email through its SMTP account | No tenant email; platform recovery and verification mail still sends | -| Telegram | Group alerts, test delivery and accept/reject buttons | No Telegram alerts or chat-based request decisions | +| Telegram | Group alerts and test delivery | No Telegram alerts | | Slack | Slack incoming-webhook destination | No Slack notification surface | | Mattermost | Mattermost incoming-webhook destination | No Mattermost notification surface | | Discord | Discord incoming-webhook destination | No Discord notification surface | diff --git a/docs/MODULES.md b/docs/MODULES.md index 58cbebef..f4a9c1bb 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -86,6 +86,13 @@ thirteen modules under it are optional. - **Without it** — not an option. Every handover in the system — staff issue, front-desk handout, member self-checkout — records itself through this workflow, and it is the only place a request's status is allowed to change. +- **Who may submit is set by `membership`, not by this module.** Being core, this module is present in every + makerspace and therefore must work with `membership` uninstalled: members only when `membership` is on, + any signed-in account when it is off, and account-less strangers only when an operator has explicitly + opted in (`manage.py set_request_access --mode anyone`, which is refused while `membership` is installed). + See the `membership` module below, and **Who may submit a borrow request** in `docs/INVARIANTS.md`. +- **Accept and reject are one request at a time.** There is no bulk accept or bulk reject anywhere — + `/control/` serves a per-request review page, and Telegram alerts carry no buttons. - **Data** — core; not separately purgeable. ### staff_admin @@ -310,6 +317,14 @@ Required by `printing`. no directory. `payments.membership` becomes inert. - **Deliberately does not require `member_accounts`.** Identity can come from external OIDC or a staff-created person record, so the two are independent switches. +- **It decides who may submit a borrow request, and installing it closes account-less requests.** With this + module on, submitting requires an active member of the makerspace. With it off, any signed-in account may + submit — a request is only a proposal that staff must accept, and waiver acceptance cannot be recorded + without a membership row anyway. Turning it ON therefore forces `anonymous_requests_enabled` OFF (audited, + never silent): the account-less path bypasses the membership check entirely, so leaving both on would let + a stranger walk past the requirement you just switched on. Turning it back off does **not** re-open + account-less requests — that is an explicit choice, made with + `manage.py set_request_access --mode anyone`. - **Data** — purgeable: join requests and member profiles with their projects and imagery. Memberships, waivers and acceptance evidence **stay** — they are core RBAC and liability state. @@ -361,11 +376,13 @@ stored credential**, so re-enabling needs no re-entry. ### telegram -- **What it is** — per-makerspace Telegram group alerts, and the accept/reject buttons on them. -- **What it adds** — the `telegram_alerts` workflow, the webhook that processes callbacks, and test - alerts. Callbacks route through the request workflow like every other actor. -- **Without it** — no Telegram alerts and no accept/reject from chat; the same decisions are made in the - console. +- **What it is** — per-makerspace Telegram group alerts. Outbound only. +- **What it adds** — the `telegram_alerts` workflow and test alerts. **No accept/reject buttons**: chat is + a notification channel, not a decision surface, so an alert names the request and points staff at the + console. The webhook route survives but acknowledges and discards every callback, which stops Telegram + retrying against a deployment that already ran `setWebhook`. +- **Without it** — no Telegram alerts. Request decisions are unaffected; they are made in the staff console + or `/control/` either way. - **Data** — purgeable: Telegram destinations and their chat ids. Delivery logs survive — the record that a message was attempted is history; the credential is the secret. diff --git a/docs/PROJECT-STATUS.md b/docs/PROJECT-STATUS.md index 1b0e6f46..bea08440 100644 --- a/docs/PROJECT-STATUS.md +++ b/docs/PROJECT-STATUS.md @@ -80,4 +80,5 @@ Stack (in use): `frontend/src/generated/api.ts`; regenerate both when routes/models change — spectacular needs `--format openapi-json`). - **Admin theme:** django-unfold; site name via `ADMIN_SITE_NAME` (default "Space Works"). -- **Telegram:** request alerts, test alerts, authenticated webhook accept/reject callbacks. +- **Telegram:** request alerts and test alerts. Outbound only — the webhook acknowledges and discards + callbacks; decisions are made in the staff console or `/control/`. diff --git a/frontend/openapi-schema.json b/frontend/openapi-schema.json index 59bb7a0f..41533643 100644 --- a/frontend/openapi-schema.json +++ b/frontend/openapi-schema.json @@ -29203,7 +29203,8 @@ "/api/v1/integrations/telegram/webhook": { "post": { "operationId": "api_v1_integrations_telegram_webhook_create", - "summary": "Receive Telegram callback webhook", + "description": "Retained so an already-registered webhook does not retry forever. Callback queries are acknowledged and discarded: accepting and rejecting borrow requests happens in the staff console, never from chat.", + "summary": "Acknowledge a Telegram webhook (no action is taken)", "tags": [ "Telegram" ], @@ -29228,7 +29229,7 @@ }, "responses": { "200": { - "description": "Webhook processed." + "description": "Acknowledged; no action taken." } } } @@ -33157,6 +33158,14 @@ "operationId": "api_v1_public_requests_create", "summary": "Submit public borrow request", "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + }, + "description": "Required for account-less submissions. Reusing a key with the same payload returns the original request; a different payload is rejected." + }, { "in": "header", "name": "X-Nonce", @@ -33194,7 +33203,7 @@ "examples": { "SubmitPublicEquipmentRequest": { "value": { - "requester_name": "Shaan Shoukath", + "contact_name": "Shaan Shoukath", "contact_email": "shaans@example.com", "contact_phone": "+919876543210", "requested_for": "Electronics workshop diagnostics", @@ -33225,7 +33234,8 @@ "security": [ { "jwtAuth": [] - } + }, + {} ], "responses": { "201": { @@ -34683,6 +34693,10 @@ "type": "string", "readOnly": true }, + "requester_contact_verified": { + "type": "boolean", + "readOnly": true + }, "status": { "type": "string", "readOnly": true @@ -34787,6 +34801,7 @@ "requested_for", "requester_contact_email", "requester_contact_phone", + "requester_contact_verified", "requester_display", "requester_name", "requester_username", @@ -51852,6 +51867,7 @@ }, "quantity": { "type": "integer", + "maximum": 99, "minimum": 1 } }, @@ -51867,15 +51883,40 @@ "type": "string", "writeOnly": true }, + "contact_name": { + "type": "string", + "description": "Required for an account-less submission.", + "maxLength": 200 + }, + "contact_email": { + "description": "Required for an account-less submission; normalized to lowercase.", + "oneOf": [ + { + "type": "string", + "format": "email", + "maxLength": 254 + }, + { + "type": "string", + "maxLength": 0 + } + ] + }, + "contact_phone": { + "type": "string", + "maxLength": 32 + }, "requested_for": { "type": "string", - "default": "" + "default": "", + "maxLength": 500 }, "items": { "type": "array", "items": { "$ref": "#/components/schemas/RequestItemInput" - } + }, + "maxItems": 20 } }, "required": [ @@ -55238,4 +55279,4 @@ "description": "Persistent staff inbox notifications." } ] -} +} \ No newline at end of file diff --git a/frontend/public/fonts/InstrumentSans-Variable.woff2 b/frontend/public/fonts/InstrumentSans-Variable.woff2 deleted file mode 100644 index 8611e41b14c75cfc8360e50d0d22a22d20a1de50..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30092 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

;7&cRT>@vBgT`qml^v#W*W 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 diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index 9db5e273..01c798f7 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; import { readStorage, writeStorage } from "../lib/safeStorage"; +import { MoonIcon, SunIcon } from "./icons"; +import { IconButton } from "./ui/IconButton"; const THEME_KEY = "makerspace.theme"; @@ -7,7 +9,7 @@ function applyTheme(theme: "light" | "dark") { document.documentElement.classList.toggle("dark", theme === "dark"); } -export function ThemeToggle() { +export function ThemeToggle({ variant = "text" }: { variant?: "text" | "icon" }) { const [theme, setTheme] = useState<"light" | "dark">(() => { const stored = readStorage(THEME_KEY); return stored === "dark" ? "dark" : "light"; @@ -18,6 +20,17 @@ export function ThemeToggle() { writeStorage(THEME_KEY, theme); }, [theme]); + if (variant === "icon") { + return ( + setTheme((current) => (current === "dark" ? "light" : "dark"))} + > + {theme === "dark" ? : } + + ); + } + return ( + + + + ) : ( + + )} + + + , + document.body, + ); +} diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index c6aa2bd4..09ab4b22 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -72,7 +72,7 @@ export function DataTable({ {selectionEnabled ? ( - + changeAll(event.target.checked)} /> ) : null} @@ -81,6 +81,7 @@ export function DataTable({ key={column.key} className={`px-3 py-2 font-semibold ${column.className ?? ""}`} aria-sort={sort?.key === column.key ? ariaSort(sort.direction) : undefined} + scope="col" > {column.sortable ? ( + ); +} + +export function IconLink({ children, className, label, ...props }: IconLinkProps) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/ui/Metric.tsx b/frontend/src/components/ui/Metric.tsx new file mode 100644 index 00000000..efc65faa --- /dev/null +++ b/frontend/src/components/ui/Metric.tsx @@ -0,0 +1,12 @@ +export function Metric({ label, value, danger = false }: { + label: string; + value?: string | number; + danger?: boolean; +}) { + return ( +

+

{label}

+

{value ?? 0}

+
+ ); +} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index eee8177c..d0240d59 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -1,6 +1,14 @@ import type React from "react"; -import { useEffect, useId, useRef } from "react"; -import { focusFirstDialogElement, trapDialogFocus } from "./dialogFocus"; +import { useEffect, useId, useRef, useSyncExternalStore } from "react"; +import { + consumePendingRestore, + focusFirstDialogElement, + getSnapshot, + popDialog, + pushDialog, + subscribe, + trapDialogFocus, +} from "./dialogFocus"; type ModalProps = { open: boolean; @@ -22,7 +30,13 @@ export function Modal({ backdrop = "plain", }: ModalProps) { const titleId = useId(); + const layerRef = useRef(null); const panelRef = useRef(null); + const tokenRef = useRef(null); + const top = useSyncExternalStore(subscribe, getSnapshot); + const amTop = top === tokenRef.current; + const amTopRef = useRef(amTop); + amTopRef.current = amTop; const maxWidthClass = size === "xl" ? "max-w-4xl" : "max-w-lg"; const backdropClass = backdrop === "blur" ? "bg-ink/35 backdrop-blur-sm" : "bg-ink/40"; @@ -36,10 +50,16 @@ export function Modal({ useEffect(() => { if (!open) return; const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const token = pushDialog({ + previousFocus, + getPanel: () => panelRef.current, + getLayer: () => layerRef.current, + }); + tokenRef.current = token; const panel = panelRef.current; - if (panel) focusFirstDialogElement(panel); const handleKeyDown = (event: KeyboardEvent) => { + if (!amTopRef.current) return; if (event.key === "Escape") onCloseRef.current(); if (panel) trapDialogFocus(event, panel); }; @@ -47,14 +67,29 @@ export function Modal({ document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); - previousFocus?.focus(); + popDialog(token); + if (tokenRef.current === token) tokenRef.current = null; }; }, [open]); + useEffect(() => { + if (!amTop) return; + consumePendingRestore(panelRef.current); + // Initial focus lives HERE, not in the push effect. A dialog that opens while another sits + // above it renders with `inert` on its layer, and `inert` is only cleared by the re-render + // that follows becoming topmost -- so focusing from the push effect silently fails, and + // consumePendingRestore no-ops because nothing was popped. This effect runs post-commit, + // once `inert` is gone. jsdom implements no `inert` at all, so no unit test can catch this. + const panel = panelRef.current; + if (panel && !panel.contains(document.activeElement)) focusFirstDialogElement(panel); + }, [amTop]); + if (!open) return null; return (
{ if (event.target === event.currentTarget) { @@ -65,7 +100,7 @@ export function Modal({
{ export default function QrScanner({ onScan, onClose }: QrScannerProps) { const titleId = useId(); + const layerRef = useRef(null); const panelRef = useRef(null); + const tokenRef = useRef(null); + const top = useSyncExternalStore(subscribe, getSnapshot); + const amTop = top === tokenRef.current; + const amTopRef = useRef(amTop); + amTopRef.current = amTop; const videoRef = useRef(null); const streamRef = useRef(null); const intervalRef = useRef(null); @@ -59,10 +73,16 @@ export default function QrScanner({ onScan, onClose }: QrScannerProps) { }, [onScan]); useEffect(() => { const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const token = pushDialog({ + previousFocus, + getPanel: () => panelRef.current, + getLayer: () => layerRef.current, + }); + tokenRef.current = token; const panel = panelRef.current; - if (panel) focusFirstDialogElement(panel); const handleKeyDown = (event: KeyboardEvent) => { + if (!amTopRef.current) return; if (event.key === "Escape") close(); if (panel) trapDialogFocus(event, panel); }; @@ -70,10 +90,23 @@ export default function QrScanner({ onScan, onClose }: QrScannerProps) { document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); - previousFocus?.focus(); + popDialog(token); + if (tokenRef.current === token) tokenRef.current = null; }; }, []); + useEffect(() => { + if (!amTop) return; + consumePendingRestore(panelRef.current); + // Initial focus lives HERE, not in the push effect. A dialog that opens while another sits + // above it renders with `inert` on its layer, and `inert` is only cleared by the re-render + // that follows becoming topmost -- so focusing from the push effect silently fails, and + // consumePendingRestore no-ops because nothing was popped. This effect runs post-commit, + // once `inert` is gone. jsdom implements no `inert` at all, so no unit test can catch this. + const panel = panelRef.current; + if (panel && !panel.contains(document.activeElement)) focusFirstDialogElement(panel); + }, [amTop]); + useEffect(() => { let cancelled = false; @@ -179,8 +212,8 @@ export default function QrScanner({ onScan, onClose }: QrScannerProps) { }; return createPortal( -
{ if (event.target === event.currentTarget) close(); }}> -
+
{ if (event.target === event.currentTarget) close(); }}> +

QR scanner

{error ? (
diff --git a/frontend/src/components/ui/dialogFocus.test.ts b/frontend/src/components/ui/dialogFocus.test.ts new file mode 100644 index 00000000..645919f3 --- /dev/null +++ b/frontend/src/components/ui/dialogFocus.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + consumePendingRestore, + getSnapshot, + isTopmost, + popDialog, + pushDialog, +} from "./dialogFocus"; + +const tokens: symbol[] = []; + +function addLayer(zIndex = "0") { + const layer = document.createElement("div"); + layer.style.zIndex = zIndex; + document.body.append(layer); + return layer; +} + +function pushLayer(layer: HTMLElement, previousFocus: HTMLElement | null = null) { + const panel = document.createElement("div"); + layer.append(panel); + const token = pushDialog({ + previousFocus, + getPanel: () => panel, + getLayer: () => layer, + }); + tokens.push(token); + return token; +} + +afterEach(() => { + while (tokens.length) popDialog(tokens.pop()!); + consumePendingRestore(null); + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe("dialog focus store", () => { + it("tracks the top through push and order-independent, idempotent pops", () => { + const first = pushLayer(addLayer("10")); + const second = pushLayer(addLayer("20")); + + expect(getSnapshot()).toBe(second); + expect(isTopmost(second)).toBe(true); + + popDialog(first); + popDialog(first); + popDialog(Symbol("unknown")); + expect(getSnapshot()).toBe(second); + + popDialog(second); + expect(getSnapshot()).toBeNull(); + }); + + it("uses document order to break equal-z ties", () => { + const earlierLayer = addLayer("100"); + const laterLayer = addLayer("100"); + + const later = pushLayer(laterLayer); + pushLayer(earlierLayer); + + expect(getSnapshot()).toBe(later); + }); + + it("lets a higher z-index win regardless of push order", () => { + const highLayer = addLayer("200"); + const lowLayer = addLayer("50"); + + const high = pushLayer(highLayer); + pushLayer(lowLayer); + + expect(getSnapshot()).toBe(high); + }); + + it("does not queue focus restoration when a non-top entry is popped", () => { + const lowerOpener = document.createElement("button"); + const currentFocus = document.createElement("button"); + const topPanel = document.createElement("div"); + const topFirst = document.createElement("button"); + document.body.append(lowerOpener, currentFocus); + + const lower = pushLayer(addLayer("10"), lowerOpener); + const topLayer = addLayer("20"); + topPanel.append(topFirst); + topLayer.append(topPanel); + const top = pushDialog({ + previousFocus: null, + getPanel: () => topPanel, + getLayer: () => topLayer, + }); + tokens.push(top); + + currentFocus.focus(); + popDialog(lower); + consumePendingRestore(topPanel); + + expect(document.activeElement).toBe(currentFocus); + }); + + it("restores a connected opener immediately when the last dialog pops", () => { + const opener = document.createElement("button"); + document.body.append(opener); + const token = pushLayer(addLayer("10"), opener); + + popDialog(token); + + expect(document.activeElement).toBe(opener); + }); + + it("falls back to document.body when the last dialog's opener is detached", () => { + const opener = document.createElement("button"); + document.body.append(opener); + const token = pushLayer(addLayer("10"), opener); + opener.remove(); + const bodyFocus = vi.spyOn(document.body, "focus"); + + popDialog(token); + + expect(bodyFocus).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/ui/dialogFocus.ts b/frontend/src/components/ui/dialogFocus.ts index 08e77c9a..45574c9a 100644 --- a/frontend/src/components/ui/dialogFocus.ts +++ b/frontend/src/components/ui/dialogFocus.ts @@ -31,3 +31,106 @@ export function trapDialogFocus(event: KeyboardEvent, panel: HTMLElement) { first.focus(); } } + +export type DialogEntry = { + token: symbol; + previousFocus: HTMLElement | null; + getPanel: () => HTMLElement | null; + getLayer: () => HTMLElement | null; +}; + +let stack: DialogEntry[] = []; +const listeners = new Set<() => void>(); +let topToken: symbol | null = null; +let pendingRestore: { previousFocus: HTMLElement | null } | null = null; + +function recomputeTop() { + let topEntry: DialogEntry | null = null; + let topLayer: HTMLElement | null = null; + let topZIndex = Number.NEGATIVE_INFINITY; + + for (const entry of stack) { + const layer = entry.getLayer(); + if (!layer?.isConnected) continue; + + const parsedZIndex = Number.parseFloat(getComputedStyle(layer).zIndex); + const zIndex = Number.isNaN(parsedZIndex) ? 0 : parsedZIndex; + const followsCurrent = + topLayer !== null && + Boolean(topLayer.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING); + + if (topEntry === null || zIndex > topZIndex || (zIndex === topZIndex && followsCurrent)) { + topEntry = entry; + topLayer = layer; + topZIndex = zIndex; + } + } + + topToken = topEntry?.token ?? null; +} + +function notifyListeners() { + listeners.forEach((listener) => listener()); +} + +function restoreFocus(previousFocus: HTMLElement | null) { + if (previousFocus?.isConnected) { + previousFocus.focus(); + } else { + document.body.focus(); + } +} + +export function subscribe(callback: () => void) { + listeners.add(callback); + return () => listeners.delete(callback); +} + +export function getSnapshot() { + return topToken; +} + +export function pushDialog(entry: Omit) { + const token = Symbol("dialog"); + stack.push({ ...entry, token }); + recomputeTop(); + notifyListeners(); + return token; +} + +export function popDialog(token: symbol) { + const index = stack.findIndex((entry) => entry.token === token); + if (index === -1) return; + + const wasTopmost = topToken === token; + const [entry] = stack.splice(index, 1); + recomputeTop(); + + if (wasTopmost) { + if (topToken === null) { + pendingRestore = null; + restoreFocus(entry.previousFocus); + } else { + pendingRestore = { previousFocus: entry.previousFocus }; + } + } + + notifyListeners(); +} + +export function isTopmost(token: symbol) { + return topToken === token; +} + +export function consumePendingRestore(panel: HTMLElement | null) { + const pending = pendingRestore; + if (!pending) return; + pendingRestore = null; + + const previousFocus = pending.previousFocus; + if (panel && previousFocus?.isConnected && panel.contains(previousFocus)) { + previousFocus.focus(); + return; + } + if (panel) focusFirstDialogElement(panel); +} diff --git a/frontend/src/components/ui/dialogStack.test.tsx b/frontend/src/components/ui/dialogStack.test.tsx new file mode 100644 index 00000000..e8344574 --- /dev/null +++ b/frontend/src/components/ui/dialogStack.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Modal } from "./Modal"; +import QrScanner from "./QrScanner"; + +type DialogHarnessProps = { + openerOutside?: boolean; + showOpener?: boolean; + showOuter?: boolean; +}; + +function DialogHarness({ + openerOutside = false, + showOpener = true, + showOuter = true, +}: DialogHarnessProps) { + const [scannerOpen, setScannerOpen] = useState(false); + + const opener = ( + + ); + + return ( + <> + + {openerOutside && showOpener ? opener : null} + undefined} title="Outer modal"> + + {!openerOutside && showOpener ? opener : null} + + {scannerOpen ? undefined} onClose={() => setScannerOpen(false)} /> : null} + + ); +} + +beforeEach(() => { + const track = { stop: vi.fn() }; + const stream = { getTracks: () => [track] } as unknown as MediaStream; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function openScanner() { + const opener = screen.getByRole("button", { name: "Open scanner" }); + opener.focus(); + fireEvent.click(opener); + await screen.findByRole("dialog", { name: "QR scanner" }); + return opener; +} + +describe("dialog stack", () => { + it("closes only the scanner on Escape and restores its exact opener", async () => { + render(); + const opener = await openScanner(); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: "QR scanner" })).toBeNull()); + expect(screen.getByRole("dialog", { name: "Outer modal" })).toBeInTheDocument(); + expect(document.activeElement).toBe(opener); + }); + + it("focuses the modal's first control when the scanner opener was removed", async () => { + const view = render(); + const opener = await openScanner(); + view.rerender(); + expect(opener.isConnected).toBe(false); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("button", { name: "Modal fallback" }))); + expect(document.activeElement).not.toBe(screen.getByRole("button", { name: "Behind modal" })); + }); + + it("rejects an opener outside the underlying modal", async () => { + render(); + const opener = await openScanner(); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("button", { name: "Modal fallback" }))); + expect(document.activeElement).not.toBe(opener); + }); + + it("keeps scanner focus when the outer modal unmounts first", async () => { + const view = render(); + await openScanner(); + const scannerControl = screen.getByRole("button", { name: "Done" }); + scannerControl.focus(); + + view.rerender(); + + expect(screen.getByRole("dialog", { name: "QR scanner" })).toBeInTheDocument(); + expect(document.activeElement).toBe(scannerControl); + expect(document.activeElement?.isConnected).toBe(true); + }); + + it("makes the covered modal layer inert and exposes only the scanner as modal", async () => { + render(); + await openScanner(); + + const modalDialog = screen.getByText("Outer modal").closest('[role="dialog"]'); + const modalLayer = modalDialog?.parentElement; + expect(modalDialog).not.toHaveAttribute("aria-modal"); + expect(modalLayer).toHaveAttribute("inert"); + expect(screen.getByRole("dialog", { name: "QR scanner" })).toHaveAttribute("aria-modal", "true"); + }); +}); diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 7257690e..201b7418 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -1,5 +1,6 @@ export { Badge } from "./Badge"; export { BulkActionToolbar } from "./BulkActionToolbar"; +export { CameraCapture } from "./CameraCapture"; export { Card } from "./Card"; export { CollapsibleSection } from "./CollapsibleSection"; export { ConfirmDialog } from "./ConfirmDialog"; @@ -7,7 +8,12 @@ export { DataTable } from "./DataTable"; export type { DataTableColumn } from "./DataTable"; export { DetailDrawer } from "./DetailDrawer"; export { EmptyState } from "./EmptyState"; +export { ErrorBlock } from "./ErrorBlock"; +export { ErrorText } from "./ErrorText"; +export { Field } from "./Field"; export { FilterBar } from "./FilterBar"; +export { IconButton, IconLink } from "./IconButton"; +export { Metric } from "./Metric"; export { Modal } from "./Modal"; export { Skeleton, SkeletonRows } from "./Skeleton"; export { Spinner } from "./Spinner"; diff --git a/frontend/src/features/bookings/PublicBookingForm.tsx b/frontend/src/features/bookings/PublicBookingForm.tsx index 316d87a0..19d3bea1 100644 --- a/frontend/src/features/bookings/PublicBookingForm.tsx +++ b/frontend/src/features/bookings/PublicBookingForm.tsx @@ -1,5 +1,6 @@ -import { cloneElement, useEffect, useRef, useState, type FormEvent, type ReactElement } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { Field } from "../../components/ui"; import { StructuredApiError } from "../../lib/api"; import { CustomFormFields } from "../forms/CustomFormFields"; import { @@ -110,13 +111,3 @@ export function PublicBookingForm({ makerspaceSlug, space }: {
); } - -function Field({ label, error, children }: { label: string; error: string; children: ReactElement<{ "aria-invalid"?: boolean }> }) { - return ( - - ); -} diff --git a/frontend/src/features/inventory/PublicEvidenceUpload.tsx b/frontend/src/features/inventory/PublicEvidenceUpload.tsx index e2596d06..7ab49195 100644 --- a/frontend/src/features/inventory/PublicEvidenceUpload.tsx +++ b/frontend/src/features/inventory/PublicEvidenceUpload.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; +import { CameraCapture } from "../../components/ui"; import { requestPublicEvidenceUpload, uploadPublicEvidenceFile, @@ -19,12 +20,17 @@ export function PublicEvidenceUpload({ const [status, setStatus] = useState<"idle" | "uploading" | "done" | "error">("idle"); const [error, setError] = useState(""); const [fileName, setFileName] = useState(""); + const [cameraOpen, setCameraOpen] = useState(false); + const [cameraSupported] = useState( + () => typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia, + ); const label = evidenceType === "issue" ? "Issue photo" : "Return photo"; useEffect(() => { setStatus("idle"); setError(""); setFileName(""); + setCameraOpen(false); onUploaded(null); }, [evidenceType, onUploaded]); @@ -51,25 +57,46 @@ export function PublicEvidenceUpload({ return (
- - {status === "uploading" ?

Uploading {fileName}...

: null} - {status === "done" ?

Photo uploaded

: null} - {status === "error" ?

{error}

: null} +
+ + {cameraSupported ? ( + + ) : null} +
+ setCameraOpen(false)} onCapture={(file) => { void handleFile(file); }} label={label} /> + {/* ONE persistent live region, not three conditional ones: a role="status" element that is + inserted at the same moment its text appears is frequently not announced, because the + region was not in the DOM for the screen reader to observe changing. */} +

+ {status === "uploading" + ? `Uploading ${fileName}...` + : status === "done" + ? "Photo uploaded" + : status === "error" + ? error + : ""} +

); } diff --git a/frontend/src/features/inventory/PublicInventoryPage.tsx b/frontend/src/features/inventory/PublicInventoryPage.tsx index 712b47e6..c1ac38b9 100644 --- a/frontend/src/features/inventory/PublicInventoryPage.tsx +++ b/frontend/src/features/inventory/PublicInventoryPage.tsx @@ -6,12 +6,10 @@ import { MakerspaceBrand } from "../../components/MakerspaceBrand"; import { MakerspaceMapLink } from "../../components/MakerspaceMapLink"; import { SpaceWorksBadge } from "../../components/SpaceWorksLogo"; import { ThemeToggle } from "../../components/ThemeToggle"; -import { Card } from "../../components/ui/Card"; +import { ChartIcon, UserIcon } from "../../components/icons"; +import { Card, Field, IconLink } from "../../components/ui"; import { useTenant, useTenantPath } from "../../lib/tenant"; -import type { - Product, - RequestCartItem, -} from "../../types/inventory"; +import type { Product, RequestCartItem } from "../../types/inventory"; import { ProductCard } from "./ProductCard"; import { ProductQuickViewModal } from "./ProductQuickViewModal"; import { @@ -24,11 +22,7 @@ import { } from "./PublicInventoryParts"; import { PublicRequestPanel } from "./PublicRequestPanel"; import { SkipLink } from "../../components/SkipLink"; -import { - usePublicCategories, - usePublicInventory, - useTenantBootstrap, -} from "./usePublicInventory"; +import { usePublicCategories, usePublicInventory, useTenantBootstrap } from "./usePublicInventory"; const PAGE_SIZE = 24; @@ -133,7 +127,7 @@ export function PublicInventoryPage() { return (
-
+

Public Inventory @@ -155,40 +149,44 @@ export function PublicInventoryPage() { className="mt-1" />

-
- -
- {inventoryQuery.data?.count ?? "-"} listed items +
+
+ {bootstrap?.makerspace.public_stats_enabled ? ( + + + + ) : null} + + + + +
+
+ +
+ {inventoryQuery.data?.count ?? "-"} listed items +
+ {modules.has("printing") ? ( + + Request a 3D print + + ) : null} + {modules.has("events") ? ( + + Events + + ) : null} + {modules.has("machines") ? ( + + Machines + + ) : null} + {modules.has("bookings") ? ( + + Book a space + + ) : null}
- {bootstrap?.makerspace.public_stats_enabled ? ( - - Stats - - ) : null} - {modules.has("printing") ? ( - - Request a 3D print - - ) : null} - {modules.has("events") ? ( - - Events - - ) : null} - {modules.has("machines") ? ( - - Machines - - ) : null} - {modules.has("bookings") ? ( - - Book a space - - ) : null} - - - Staff login -
@@ -203,16 +201,15 @@ export function PublicInventoryPage() {
-
- setSearchInput(event.target.value)} - /> + + + setSearchInput(event.target.value)} + /> + diff --git a/frontend/src/features/staff/ApiClientCreateCard.tsx b/frontend/src/features/staff/ApiClientCreateCard.tsx index 32f3fcbe..92f6e104 100644 --- a/frontend/src/features/staff/ApiClientCreateCard.tsx +++ b/frontend/src/features/staff/ApiClientCreateCard.tsx @@ -1,3 +1,4 @@ +import { Field } from "../../components/ui"; import { ApiClientScopePicker } from "./ApiClientScopePicker"; import type { ApiClientCreateResponse, ApiClientScopeOption } from "./apiClientsApi"; import { splitOrigins } from "./apiClientsApi"; @@ -48,18 +49,21 @@ export function ApiClientCreateCard({

API clients

- onLabelChange(event.target.value)} - /> -