diff --git a/libreforms_fastapi/app/__init__.py b/libreforms_fastapi/app/__init__.py index 89ed8a1..88395c1 100644 --- a/libreforms_fastapi/app/__init__.py +++ b/libreforms_fastapi/app/__init__.py @@ -1084,10 +1084,14 @@ async def api_form_sign( except SignatureError as e: raise HTTPException(status_code=403, detail=f"{e}") + except InsufficientPermissions as e: + raise HTTPException(status_code=403, detail=f"{e}") except DocumentAlreadyHasValidSignature as e: raise HTTPException(status_code=200, detail=f"{e}") + except NoChangesProvided as e: + raise HTTPException(status_code=200, detail=f"{e}") # Send email if config.SMTP_ENABLED: @@ -1118,6 +1122,95 @@ async def api_form_sign( "document_id": document_id, } + +@app.patch("/api/form/unsign/{form_name}/{document_id}", dependencies=[Depends(api_key_auth)]) +async def api_form_sign( + form_name:str, + document_id:str, + background_tasks: BackgroundTasks, + request: Request, + session: SessionLocal = Depends(get_db), + key: str = Depends(X_API_KEY), +): + if form_name not in get_form_names(): + raise HTTPException(status_code=404, detail=f"Form '{form_name}' not found") + + # Ugh, I'd like to find a more efficient way to get the user data. But alas, that + # the sqlalchemy-signing table is not optimized alongside the user model... + user = session.query(User).filter_by(api_key=key).first() + + # Here we validate the user groups permit this level of access to the form + try: + user.validate_permission(form_name=form_name, required_permission="sign_own") + except Exception as e: + raise HTTPException(status_code=403, detail=f"{e}") + + metadata={ + doc_db.last_editor_field: user.username, + } + + # Add the remote addr host if enabled + if config.COLLECT_USAGE_STATISTICS: + metadata[doc_db.ip_address_field] = request.client.host + + try: + # Process the request as needed + success = doc_db.sign_document( + form_name=form_name, + document_id=document_id, + metadata=metadata, + username=user.username, + public_key=user.public_key, + private_key_path=user.private_key_ref, + unsign=True, + ) + + # Unlike other methods, like get_one_document or fuzzy_search_documents, this method raises exceptions when + # it fails to ensure the user knows their operation was not successful. + except DocumentDoesNotExist as e: + raise HTTPException(status_code=404, detail=f"{e}") + + except DocumentIsDeleted as e: + raise HTTPException(status_code=410, detail=f"{e}") + + except SignatureError as e: + raise HTTPException(status_code=403, detail=f"{e}") + + except InsufficientPermissions as e: + raise HTTPException(status_code=403, detail=f"{e}") + + except NoChangesProvided as e: + raise HTTPException(status_code=200, detail=f"{e}") + + # Send email + if config.SMTP_ENABLED: + background_tasks.add_task( + mailer.send_mail, + subject="Form Unsigned", + content=f"This email servers to notify you that a form was unsigned at {config.DOMAIN} by the user registered at this email address. The form's document ID is '{document_id}'. If you believe this was a mistake, or did not intend to sign this form, please contact your system administrator.", + to_address=user.email, + ) + + # Write this query to the TransactionLog + if config.COLLECT_USAGE_STATISTICS: + + endpoint = request.url.path + remote_addr = request.client.host + + background_tasks.add_task( + write_api_call_to_transaction_log, + api_key=key, + endpoint=endpoint, + remote_addr=remote_addr, + query_params={}, + ) + + return { + "message": "Form successfully unsigned", + "document_id": document_id, + } + + # Approve form # This is a metadata-only field. It should not impact the data, just the metadata - namely, to afix # an approval - in the format of a digital signature - to the form. diff --git a/libreforms_fastapi/utils/document_database.py b/libreforms_fastapi/utils/document_database.py index 845183d..e66bc80 100644 --- a/libreforms_fastapi/utils/document_database.py +++ b/libreforms_fastapi/utils/document_database.py @@ -398,7 +398,7 @@ def update_document( if len(dropping_unchanged_data.keys()) == 0: raise NoChangesProvided(form_name, document_id) - print("\n\n\nDropping Unchanged Fields: ", dropping_unchanged_data) + # print("\n\n\nDropping Unchanged Fields: ", dropping_unchanged_data) # Build the journal journal = document['metadata'].get(self.journal_field) @@ -441,6 +441,7 @@ def sign_document( metadata={}, exclude_deleted=True, verify_on_sign=True, + unsign=False, ): """ Manage signatures existing form in specified form's database. @@ -464,25 +465,41 @@ def sign_document( self.logger.warning(f"Document for {form_name} with document_id {document_id} is deleted and was not updated") raise DocumentIsDeleted(form_name, document_id) - # Before we even begin, we verify whether a signature exists and only proceed if it doesn't. Otherwise, - # we raise a DocumentAlreadyHasValidSignature exception. The idea here is to avoid spamming signatures if - # there has been no substantive change to the data since a past signature. This will allow the logic here - # to proceed if there is no signature, or if the data has changed since the last signature. - has_document_already_been_signed = verify_record_signature(record=document, username=username, env=self.env, public_key=public_key, private_key_path=private_key_path) - - if has_document_already_been_signed: - raise DocumentAlreadyHasValidSignature(form_name, document_id, username) - - # Now we afix the signature - try: - r, signature = sign_record(record=document, username=username, env=self.env, private_key_path=private_key_path) - - if verify_on_sign: - verify = verify_record_signature(record=r, username=username, env=self.env, public_key=public_key, private_key_path=private_key_path) - assert (verify) - # print ("\n\n\n", a) - except: - raise SignatureError(form_name, document_id, username) + if username != document['metadata'][self.created_by_field]: + if self.use_logger: + self.logger.warning(f"Insufficient permissions to {'unsign' if unsign else 'sign'} document for {form_name} with document_id {document_id}") + raise InsufficientPermissions(form_name, document_id, username) + + # If we are trying to unsign the document, then we remove the signature, update the document, and return. + if unsign: + + # If the document is not signed, raise a no changes exception + if not document['metadata'][self.signature_field]: + raise NoChangesProvided(form_name, document_id) + + signature = None + + else: + + # Before we even begin, we verify whether a signature exists and only proceed if it doesn't. Otherwise, + # we raise a DocumentAlreadyHasValidSignature exception. The idea here is to avoid spamming signatures if + # there has been no substantive change to the data since a past signature. This will allow the logic here + # to proceed if there is no signature, or if the data has changed since the last signature. + has_document_already_been_signed = verify_record_signature(record=document, username=username, env=self.env, public_key=public_key, private_key_path=private_key_path) + + if has_document_already_been_signed: + raise DocumentAlreadyHasValidSignature(form_name, document_id, username) + + # Now we afix the signature + try: + r, signature = sign_record(record=document, username=username, env=self.env, private_key_path=private_key_path) + + if verify_on_sign: + verify = verify_record_signature(record=r, username=username, env=self.env, public_key=public_key, private_key_path=private_key_path) + assert (verify) + # print ("\n\n\n", a) + except: + raise SignatureError(form_name, document_id, username) current_timestamp = datetime.now(self.timezone)