From 134b2f70e4cf947744f1b061766bb37fe616ad65 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 5 Feb 2022 16:50:11 +0400 Subject: Overhaul Scope System Adds discord role support to the pre-existing scopes system to power more complex access permissions. Signed-off-by: Hassan Abouelela --- backend/routes/forms/submit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'backend/routes/forms') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 95e30b0..baf403d 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -83,7 +83,7 @@ class SubmitForm(Route): try: if hasattr(request.user, User.refresh_data.__name__): old = request.user.token - await request.user.refresh_data() + await request.user.refresh_data(request.state.db) if old != request.user.token: try: -- cgit v1.2.3 From 513de6945d40b66368a061dff6a81646e8bda7a0 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 5 Feb 2022 17:39:33 +0400 Subject: Add Role Based Authorized Readers Adds a new property on forms to declare which roles are authorized to access form responses. Signed-off-by: Hassan Abouelela --- backend/discord.py | 38 ++++++++++++++++++++++++++++++++++++++ backend/models/form.py | 12 ++++++++++-- backend/routes/forms/discover.py | 2 +- backend/routes/forms/response.py | 18 ++++++++++++++---- backend/routes/forms/responses.py | 17 ++++++++++------- 5 files changed, 73 insertions(+), 14 deletions(-) (limited to 'backend/routes/forms') diff --git a/backend/discord.py b/backend/discord.py index 51de26a..4e35216 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -5,6 +5,7 @@ import json import typing import httpx +import starlette.requests from pymongo.database import Database from backend import constants, models @@ -150,3 +151,40 @@ async def get_member( "inserted_at": datetime.datetime.now(tz=datetime.timezone.utc), }) return member + + +class FormNotFoundError(Exception): + """The requested form was not found.""" + + +async def _verify_access_helper( + form_id: str, request: starlette.requests.Request, attribute: str +) -> bool: + """A low level helper to validate access to a form resource based on the user's scopes.""" + # Short circuit all resources for admins + if "admin" in request.auth.scopes: + return True + + form = await request.state.db.forms.find_one({"id": form_id}) + + if not form: + raise FormNotFoundError() + + form = models.Form(**form) + + for role_id in getattr(form, attribute) or []: + role = await request.state.db.roles.find_one({"id": role_id}) + if not role: + continue + + role = models.DiscordRole(**json.loads(role["data"])) + + if role.name in request.auth.scopes: + return True + + return False + + +async def verify_response_access(form_id: str, request: starlette.requests.Request) -> bool: + """Ensure the user can access responses on the requested resource.""" + return await _verify_access_helper(form_id, request, "response_readers") diff --git a/backend/models/form.py b/backend/models/form.py index f19ed85..45a7e0b 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -1,10 +1,10 @@ import typing as t import httpx -from pydantic import constr, BaseModel, Field, root_validator, validator +from pydantic import BaseModel, Field, constr, root_validator, validator from pydantic.error_wrappers import ErrorWrapper, ValidationError -from backend.constants import FormFeatures, WebHook +from backend.constants import DISCORD_GUILD, FormFeatures, WebHook from .question import Question PUBLIC_FIELDS = [ @@ -43,6 +43,7 @@ class Form(BaseModel): submitted_text: t.Optional[str] = None webhook: _WebHook = None discord_role: t.Optional[str] + response_readers: t.Optional[list[str]] class Config: allow_population_by_field_name = True @@ -67,6 +68,13 @@ class Form(BaseModel): return value + @validator("response_readers") + def validate_role_scoping(cls, value: t.Optional[list[str]]): + """Ensure special role based permissions aren't granted to the @everyone role.""" + if value and str(DISCORD_GUILD) in value: + raise ValueError("You can not add the everyone role as an access scope.") + return value + @root_validator def validate_role(cls, values: dict[str, t.Any]) -> t.Optional[dict[str, t.Any]]: """Validates does Discord role provided when flag provided.""" diff --git a/backend/routes/forms/discover.py b/backend/routes/forms/discover.py index d7351d5..b993075 100644 --- a/backend/routes/forms/discover.py +++ b/backend/routes/forms/discover.py @@ -29,7 +29,7 @@ EMPTY_FORM = Form( features=__FEATURES, questions=[__QUESTION], name="Auth form", - description="An empty form to help you get a token." + description="An empty form to help you get a token.", ) diff --git a/backend/routes/forms/response.py b/backend/routes/forms/response.py index d8d8d17..fbf8e99 100644 --- a/backend/routes/forms/response.py +++ b/backend/routes/forms/response.py @@ -1,11 +1,13 @@ """ Returns or deletes form response by ID. """ + from spectree import Response as RouteResponse from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse +from backend import discord from backend.models import FormResponse from backend.route import Route from backend.validation import ErrorMessage, OkayResponse, api @@ -17,23 +19,31 @@ class Response(Route): name = "response" path = "/{form_id:str}/responses/{response_id:str}" - @requires(["authenticated", "admin"]) + @requires(["authenticated"]) @api.validate( - resp=RouteResponse(HTTP_200=FormResponse, HTTP_404=ErrorMessage), + resp=RouteResponse(HTTP_200=FormResponse, HTTP_401=ErrorMessage, HTTP_404=ErrorMessage), tags=["forms", "responses"] ) async def get(self, request: Request) -> JSONResponse: """Return a single form response by ID.""" + form_id = request.path_params["form_id"] + + try: + if not await discord.verify_response_access(form_id, request): + return JSONResponse({"error": "unauthorized"}, status_code=401) + except discord.FormNotFoundError: + return JSONResponse({"error": "form_not_found"}, status_code=404) + if raw_response := await request.state.db.responses.find_one( { "_id": request.path_params["response_id"], - "form_id": request.path_params["form_id"] + "form_id": form_id } ): response = FormResponse(**raw_response) return JSONResponse(response.dict()) else: - return JSONResponse({"error": "not_found"}, status_code=404) + return JSONResponse({"error": "response_not_found"}, status_code=404) @requires(["authenticated", "admin"]) @api.validate( diff --git a/backend/routes/forms/responses.py b/backend/routes/forms/responses.py index f3c4cd7..1c8ebe3 100644 --- a/backend/routes/forms/responses.py +++ b/backend/routes/forms/responses.py @@ -7,9 +7,10 @@ from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse +from backend import discord from backend.models import FormResponse, ResponseList from backend.route import Route -from backend.validation import api, ErrorMessage, OkayResponse +from backend.validation import ErrorMessage, OkayResponse, api class ResponseIdList(BaseModel): @@ -24,20 +25,22 @@ class Responses(Route): name = "form_responses" path = "/{form_id:str}/responses" - @requires(["authenticated", "admin"]) + @requires(["authenticated"]) @api.validate( - resp=Response(HTTP_200=ResponseList, HTTP_404=ErrorMessage), + resp=Response(HTTP_200=ResponseList, HTTP_401=ErrorMessage, HTTP_404=ErrorMessage), tags=["forms", "responses"] ) async def get(self, request: Request) -> JSONResponse: """Returns all form responses by form ID.""" - if not await request.state.db.forms.find_one( - {"_id": request.path_params["form_id"]} - ): + form_id = request.path_params["form_id"] + try: + if not await discord.verify_response_access(form_id, request): + return JSONResponse({"error": "unauthorized"}, 401) + except discord.FormNotFoundError: return JSONResponse({"error": "not_found"}, 404) cursor = request.state.db.responses.find( - {"form_id": request.path_params["form_id"]} + {"form_id": form_id} ) responses = [ FormResponse(**response) for response in await cursor.to_list(None) -- cgit v1.2.3 From 7ae2d8714a5fd11155ee3974c1b7cd1be20ac56d Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 5 Feb 2022 18:32:06 +0400 Subject: Add Role Based Editors To Forms Adds the ability to specify who can edit forms using discord roles. Signed-off-by: Hassan Abouelela --- backend/discord.py | 5 +++ backend/models/form.py | 3 +- backend/routes/forms/form.py | 71 +++++++++++++++++++++++++------------------ backend/routes/forms/index.py | 6 ++-- 4 files changed, 51 insertions(+), 34 deletions(-) (limited to 'backend/routes/forms') diff --git a/backend/discord.py b/backend/discord.py index 4e35216..6c8eefe 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -188,3 +188,8 @@ async def _verify_access_helper( async def verify_response_access(form_id: str, request: starlette.requests.Request) -> bool: """Ensure the user can access responses on the requested resource.""" return await _verify_access_helper(form_id, request, "response_readers") + + +async def verify_edit_access(form_id: str, request: starlette.requests.Request) -> bool: + """Ensure the user can view and modify the requested resource.""" + return await _verify_access_helper(form_id, request, "editors") diff --git a/backend/models/form.py b/backend/models/form.py index 45a7e0b..4ee2804 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -44,6 +44,7 @@ class Form(BaseModel): webhook: _WebHook = None discord_role: t.Optional[str] response_readers: t.Optional[list[str]] + editors: t.Optional[list[str]] class Config: allow_population_by_field_name = True @@ -68,7 +69,7 @@ class Form(BaseModel): return value - @validator("response_readers") + @validator("response_readers", "editors") def validate_role_scoping(cls, value: t.Optional[list[str]]): """Ensure special role based permissions aren't granted to the @everyone role.""" if value and str(DISCORD_GUILD) in value: diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index 0f96b85..15ff9a6 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -10,13 +10,16 @@ from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse -from backend import constants +from backend import constants, discord from backend.models import Form from backend.route import Route from backend.routes.forms.discover import EMPTY_FORM from backend.routes.forms.unittesting import filter_unittests from backend.validation import ErrorMessage, OkayResponse, api +NOT_FOUND_ERROR = JSONResponse({"error": "not_found"}, status_code=404) +PUBLIC_FORM_FEATURES = (constants.FormFeatures.OPEN, constants.FormFeatures.DISCOVERABLE) + class SingleForm(Route): """ @@ -31,9 +34,17 @@ class SingleForm(Route): @api.validate(resp=Response(HTTP_200=Form, HTTP_404=ErrorMessage), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Returns single form information by ID.""" - admin = request.user.admin if request.user.is_authenticated else False form_id = request.path_params["form_id"].lower() + try: + admin = await discord.verify_edit_access(form_id, request) + except discord.FormNotFoundError: + if not constants.PRODUCTION and form_id == EMPTY_FORM.id: + # Empty form to help with authentication in development. + return JSONResponse(EMPTY_FORM.dict(admin=False)) + else: + return NOT_FOUND_ERROR + filters = { "_id": form_id } @@ -41,25 +52,19 @@ class SingleForm(Route): if not admin: filters["features"] = {"$in": ["OPEN", "DISCOVERABLE"]} - if raw_form := await request.state.db.forms.find_one(filters): - form = Form(**raw_form) - if not admin: - form = filter_unittests(form) - - return JSONResponse(form.dict(admin=admin)) - - elif not constants.PRODUCTION and form_id == EMPTY_FORM.id: - # Empty form to help with authentication in development. - return JSONResponse(EMPTY_FORM.dict(admin=admin)) + form = Form(**await request.state.db.forms.find_one(filters)) + if not admin: + form = filter_unittests(form) - return JSONResponse({"error": "not_found"}, status_code=404) + return JSONResponse(form.dict(admin=admin)) - @requires(["authenticated", "admin"]) + @requires(["authenticated"]) @api.validate( resp=Response( HTTP_200=OkayResponse, HTTP_400=ErrorMessage, - HTTP_404=ErrorMessage + HTTP_401=ErrorMessage, + HTTP_404=ErrorMessage, ), tags=["forms"] ) @@ -70,10 +75,17 @@ class SingleForm(Route): except json.decoder.JSONDecodeError: return JSONResponse("Expected a JSON body.", 400) - form_id = {"_id": request.path_params["form_id"].lower()} - if raw_form := await request.state.db.forms.find_one(form_id): + form_id = request.path_params["form_id"].lower() + + try: + if not await discord.verify_edit_access(form_id, request): + return JSONResponse({"error": "unauthorized"}, status_code=401) + except discord.FormNotFoundError: + return NOT_FOUND_ERROR + + if raw_form := await request.state.db.forms.find_one({"id": form_id}): if "_id" in data or "id" in data: - if (data.get("id") or data.get("_id")) != form_id["_id"]: + if (data.get("id") or data.get("_id")) != form_id: return JSONResponse({"error": "locked_field"}, status_code=400) # Build Data Merger @@ -90,7 +102,7 @@ class SingleForm(Route): except ValidationError as e: return JSONResponse(e.errors(), status_code=422) - await request.state.db.forms.replace_one(form_id, form.dict()) + await request.state.db.forms.replace_one({"id": form_id}, form.dict()) return JSONResponse(form.dict()) else: @@ -98,21 +110,20 @@ class SingleForm(Route): @requires(["authenticated", "admin"]) @api.validate( - resp=Response(HTTP_200=OkayResponse, HTTP_404=ErrorMessage), + resp=Response(HTTP_200=OkayResponse, HTTP_401=ErrorMessage, HTTP_404=ErrorMessage), tags=["forms"] ) async def delete(self, request: Request) -> JSONResponse: """Deletes form by ID.""" - if not await request.state.db.forms.find_one( - {"_id": request.path_params["form_id"].lower()} - ): - return JSONResponse({"error": "not_found"}, status_code=404) + form_id = request.path_params["form_id"].lower() + + try: + if not await discord.verify_edit_access(form_id, request): + return JSONResponse({"error": "unauthorized"}, status_code=401) + except discord.FormNotFoundError: + return NOT_FOUND_ERROR - await request.state.db.forms.delete_one( - {"_id": request.path_params["form_id"].lower()} - ) - await request.state.db.responses.delete_many( - {"form_id": request.path_params["form_id"].lower()} - ) + await request.state.db.forms.delete_one({"_id": form_id}) + await request.state.db.responses.delete_many({"form_id": form_id}) return JSONResponse({"status": "ok"}) diff --git a/backend/routes/forms/index.py b/backend/routes/forms/index.py index 22171fa..38be693 100644 --- a/backend/routes/forms/index.py +++ b/backend/routes/forms/index.py @@ -15,13 +15,13 @@ from backend.validation import ErrorMessage, OkayResponse, api class FormsList(Route): """ - List all available forms for administrator viewing. + List all available forms for authorized viewers. """ name = "forms_list_create" path = "/" - @requires(["authenticated", "admin"]) + @requires(["authenticated", "Admins"]) @api.validate(resp=Response(HTTP_200=FormList), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Return a list of all forms to authenticated users.""" @@ -38,7 +38,7 @@ class FormsList(Route): forms ) - @requires(["authenticated", "admin"]) + @requires(["authenticated", "Helpers"]) @api.validate( json=Form, resp=Response(HTTP_200=OkayResponse, HTTP_400=ErrorMessage), -- cgit v1.2.3 From 0344d9a2137eb755f002398aac40533e8ea46776 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 5 Feb 2022 20:14:05 +0400 Subject: Use HTTPException To Propagate Access Failures Co-authored-by: Bluenix Signed-off-by: Hassan Abouelela --- backend/discord.py | 25 +++++++++++++++---------- backend/routes/forms/form.py | 24 +++++++----------------- backend/routes/forms/response.py | 9 ++------- backend/routes/forms/responses.py | 8 ++------ 4 files changed, 26 insertions(+), 40 deletions(-) (limited to 'backend/routes/forms') diff --git a/backend/discord.py b/backend/discord.py index f972f5f..856e878 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -7,6 +7,7 @@ import typing import httpx import starlette.requests from pymongo.database import Database +from starlette import exceptions from backend import constants, models @@ -151,22 +152,26 @@ async def get_member( return member -class FormNotFoundError(Exception): +class FormNotFoundError(exceptions.HTTPException): """The requested form was not found.""" +class UnauthorizedError(exceptions.HTTPException): + """You are not authorized to use this resource.""" + + async def _verify_access_helper( form_id: str, request: starlette.requests.Request, attribute: str -) -> bool: +) -> None: """A low level helper to validate access to a form resource based on the user's scopes.""" # Short circuit all resources for admins if "admin" in request.auth.scopes: - return True + return form = await request.state.db.forms.find_one({"id": form_id}) if not form: - raise FormNotFoundError() + raise FormNotFoundError(status_code=404) form = models.Form(**form) @@ -178,16 +183,16 @@ async def _verify_access_helper( role = models.DiscordRole(**json.loads(role["data"])) if role.name in request.auth.scopes: - return True + return - return False + raise UnauthorizedError(status_code=401) -async def verify_response_access(form_id: str, request: starlette.requests.Request) -> bool: +async def verify_response_access(form_id: str, request: starlette.requests.Request) -> None: """Ensure the user can access responses on the requested resource.""" - return await _verify_access_helper(form_id, request, "response_readers") + await _verify_access_helper(form_id, request, "response_readers") -async def verify_edit_access(form_id: str, request: starlette.requests.Request) -> bool: +async def verify_edit_access(form_id: str, request: starlette.requests.Request) -> None: """Ensure the user can view and modify the requested resource.""" - return await _verify_access_helper(form_id, request, "editors") + await _verify_access_helper(form_id, request, "editors") diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index 15ff9a6..b6c6f1d 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -17,7 +17,6 @@ from backend.routes.forms.discover import EMPTY_FORM from backend.routes.forms.unittesting import filter_unittests from backend.validation import ErrorMessage, OkayResponse, api -NOT_FOUND_ERROR = JSONResponse({"error": "not_found"}, status_code=404) PUBLIC_FORM_FEATURES = (constants.FormFeatures.OPEN, constants.FormFeatures.DISCOVERABLE) @@ -37,13 +36,15 @@ class SingleForm(Route): form_id = request.path_params["form_id"].lower() try: - admin = await discord.verify_edit_access(form_id, request) + await discord.verify_edit_access(form_id, request) + admin = True except discord.FormNotFoundError: if not constants.PRODUCTION and form_id == EMPTY_FORM.id: # Empty form to help with authentication in development. return JSONResponse(EMPTY_FORM.dict(admin=False)) - else: - return NOT_FOUND_ERROR + raise + except discord.UnauthorizedError: + admin = False filters = { "_id": form_id @@ -63,7 +64,6 @@ class SingleForm(Route): resp=Response( HTTP_200=OkayResponse, HTTP_400=ErrorMessage, - HTTP_401=ErrorMessage, HTTP_404=ErrorMessage, ), tags=["forms"] @@ -76,12 +76,7 @@ class SingleForm(Route): return JSONResponse("Expected a JSON body.", 400) form_id = request.path_params["form_id"].lower() - - try: - if not await discord.verify_edit_access(form_id, request): - return JSONResponse({"error": "unauthorized"}, status_code=401) - except discord.FormNotFoundError: - return NOT_FOUND_ERROR + await discord.verify_edit_access(form_id, request) if raw_form := await request.state.db.forms.find_one({"id": form_id}): if "_id" in data or "id" in data: @@ -116,12 +111,7 @@ class SingleForm(Route): async def delete(self, request: Request) -> JSONResponse: """Deletes form by ID.""" form_id = request.path_params["form_id"].lower() - - try: - if not await discord.verify_edit_access(form_id, request): - return JSONResponse({"error": "unauthorized"}, status_code=401) - except discord.FormNotFoundError: - return NOT_FOUND_ERROR + await discord.verify_edit_access(form_id, request) await request.state.db.forms.delete_one({"_id": form_id}) await request.state.db.responses.delete_many({"form_id": form_id}) diff --git a/backend/routes/forms/response.py b/backend/routes/forms/response.py index fbf8e99..565701f 100644 --- a/backend/routes/forms/response.py +++ b/backend/routes/forms/response.py @@ -21,18 +21,13 @@ class Response(Route): @requires(["authenticated"]) @api.validate( - resp=RouteResponse(HTTP_200=FormResponse, HTTP_401=ErrorMessage, HTTP_404=ErrorMessage), + resp=RouteResponse(HTTP_200=FormResponse, HTTP_404=ErrorMessage), tags=["forms", "responses"] ) async def get(self, request: Request) -> JSONResponse: """Return a single form response by ID.""" form_id = request.path_params["form_id"] - - try: - if not await discord.verify_response_access(form_id, request): - return JSONResponse({"error": "unauthorized"}, status_code=401) - except discord.FormNotFoundError: - return JSONResponse({"error": "form_not_found"}, status_code=404) + await discord.verify_response_access(form_id, request) if raw_response := await request.state.db.responses.find_one( { diff --git a/backend/routes/forms/responses.py b/backend/routes/forms/responses.py index 1c8ebe3..818ebce 100644 --- a/backend/routes/forms/responses.py +++ b/backend/routes/forms/responses.py @@ -27,17 +27,13 @@ class Responses(Route): @requires(["authenticated"]) @api.validate( - resp=Response(HTTP_200=ResponseList, HTTP_401=ErrorMessage, HTTP_404=ErrorMessage), + resp=Response(HTTP_200=ResponseList), tags=["forms", "responses"] ) async def get(self, request: Request) -> JSONResponse: """Returns all form responses by form ID.""" form_id = request.path_params["form_id"] - try: - if not await discord.verify_response_access(form_id, request): - return JSONResponse({"error": "unauthorized"}, 401) - except discord.FormNotFoundError: - return JSONResponse({"error": "not_found"}, 404) + await discord.verify_response_access(form_id, request) cursor = request.state.db.responses.find( {"form_id": form_id} -- cgit v1.2.3 From 27b0c38694f6bdd557c58a6bd6c49e5c95d5cfa5 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 14 Mar 2022 07:14:49 +0300 Subject: Explicitly Use `_id` Over `id` Co-authored-by: Kieran Siek --- backend/discord.py | 4 ++-- backend/routes/forms/form.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) (limited to 'backend/routes/forms') diff --git a/backend/discord.py b/backend/discord.py index 5a734db..be12109 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -164,12 +164,12 @@ async def _verify_access_helper( form_id: str, request: starlette.requests.Request, attribute: str ) -> None: """A low level helper to validate access to a form resource based on the user's scopes.""" - form = await request.state.db.forms.find_one({"id": form_id}) + form = await request.state.db.forms.find_one({"_id": form_id}) if not form: raise FormNotFoundError(status_code=404) - # Short circuit all resources for admins + # Short circuit all resources for forms admins if "admin" in request.auth.scopes: return diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index b6c6f1d..567c197 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -78,7 +78,7 @@ class SingleForm(Route): form_id = request.path_params["form_id"].lower() await discord.verify_edit_access(form_id, request) - if raw_form := await request.state.db.forms.find_one({"id": form_id}): + if raw_form := await request.state.db.forms.find_one({"_id": form_id}): if "_id" in data or "id" in data: if (data.get("id") or data.get("_id")) != form_id: return JSONResponse({"error": "locked_field"}, status_code=400) @@ -97,7 +97,7 @@ class SingleForm(Route): except ValidationError as e: return JSONResponse(e.errors(), status_code=422) - await request.state.db.forms.replace_one({"id": form_id}, form.dict()) + await request.state.db.forms.replace_one({"_id": form_id}, form.dict()) return JSONResponse(form.dict()) else: -- cgit v1.2.3