Skip to content

Commit

Permalink
Added: sign document route (#71) (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
signebedi committed Mar 26, 2024
1 parent 85f5db4 commit ebd0d9d
Show file tree
Hide file tree
Showing 2 changed files with 157 additions and 12 deletions.
89 changes: 85 additions & 4 deletions libreforms_fastapi/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ async def start_test_logger():
"example_form:update_own",
"example_form:update_all",
"example_form:delete_own",
"example_form:delete_all"
"example_form:delete_all",
"example_form:sign_own"
]
default_group = Group(id=1, name="default", permissions=default_permissions)
session.add(default_group)
Expand Down Expand Up @@ -1013,13 +1014,93 @@ async def api_form_search_all(
# Sign form
# This is a metadata-only field. It should not impact the data, just the metadata - namely, to afix
# a digital signature to the form. See https://github.com/signebedi/libreforms-fastapi/issues/59.
@app.patch("/api/form/sign/{form_name}/{document_id}")
async def api_form_sign():
@app.patch("/api/form/sign/{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),
):

# The underlying principle is that the user can only sign their own form. The question is what
# part of the application decides: the API, or the document database?

pass
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:
for group in user.groups:
print("\n\n", group.get_permissions())

user.validate_permission(form_name=form_name, required_permission="sign_own")
except Exception as e:
raise HTTPException(status_code=403, detail=f"{e}")

# "example_form:sign_own"

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,
)

# 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}")


# Send email
if config.SMTP_ENABLED:
background_tasks.add_task(
mailer.send_mail,
subject="Form Signed",
content=f"This email servers to notify you that a form was signed 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 signed",
"document_id": document_id,
}

# Approve form
# This is a metadata-only field. It should not impact the data, just the metadata - namely, to afix
Expand Down
80 changes: 72 additions & 8 deletions libreforms_fastapi/utils/document_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@

from libreforms_fastapi.utils.logging import set_logger

# This import is used to afix digital signatures to records
from libreforms_fastapi.utils.certificates import sign_record


# We want to modify TinyDB use use string representations of bson
# ObjectIDs. As such, we will need to modify some underlying behavior,
# see https://github.com/signebedi/libreforms-fastapi/issues/15.
Expand Down Expand Up @@ -327,12 +331,13 @@ def __init__(self, form_names_callable, timezone: ZoneInfo, db_path: str = "inst
self.db_path = db_path
os.makedirs(self.db_path, exist_ok=True)

self.env = env
self.log_name = "tinydb.log"
self.use_logger = use_logger

if self.use_logger:
self.logger = set_logger(
environment=env,
environment=self.env,
log_file_name=self.log_name,
namespace=self.log_name
)
Expand Down Expand Up @@ -488,9 +493,6 @@ def update_document(self, form_name:str, document_id:str, json_data:str, metadat
document['metadata'][self.ip_address_field] = metadata.get(self.ip_address_field, None)
document['metadata'][self.journal_field] = journal


# print("\n\n\nUpdated Document: ", document)

# Update only the fields that are provided in json_data and metadata, not replacing the entire
# document. The partial approach will minimize the room for mistakes from overwriting entire documents.
_ = self.databases[form_name].update(document, doc_ids=[document_id])
Expand All @@ -500,13 +502,75 @@ def update_document(self, form_name:str, document_id:str, json_data:str, metadat

return document

def sign_document(self, form_name:str, json_data, metadata={}):
"""Manage signatures existing form in specified form's database."""
def sign_document(
self,
form_name:str,
document_id:str,
username:str,
public_key=None,
private_key_path=None,
metadata={},
exclude_deleted=True
):
"""
Manage signatures existing form in specified form's database.
# Placeholder for logger
This is a metadata-only method. The actual form data should not be touched.
"""

self._check_form_exists(form_name)

pass
# Ensure the document exists
document = self.databases[form_name].get(doc_id=document_id)
if not document:
if self.use_logger:
self.logger.warning(f"No document for {form_name} with document_id {document_id}")
raise DocumentDoesNotExist(form_name, document_id)

# If exclude_deleted is set, then we return None if the document is marked as deleted
if exclude_deleted and document['metadata'][self.is_deleted_field] == True:
if self.use_logger:
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)

# Now we afix the signature
_, signature = sign_record(record=document, username=username, env=self.env)

# Placeholder - before proceeding, should we verify the signature and raise an
# SignatureError exception if the verification fails?

current_timestamp = datetime.now(self.timezone)

# Build the journal
journal = document['metadata'].get(self.journal_field)
journal.append (
{
self.signature_field: signature,
self.last_modified_field: current_timestamp.isoformat(),
self.last_editor_field: metadata.get(self.last_editor_field, None),
self.ip_address_field: metadata.get(self.ip_address_field, None),
}
)


# Here we update only a few metadata fields ... fields like approval and signature should be
# handled through separate API calls.
document['metadata'][self.last_modified_field] = current_timestamp.isoformat()
document['metadata'][self.last_editor_field] = metadata.get(self.last_editor_field, None)
document['metadata'][self.ip_address_field] = metadata.get(self.ip_address_field, None)
document['metadata'][self.journal_field] = journal
document['metadata'][self.signature_field] = signature


# Update only the fields that are provided in json_data and metadata, not replacing the entire
# document. The partial approach will minimize the room for mistakes from overwriting entire documents.
_ = self.databases[form_name].update(document, doc_ids=[document_id])

if self.use_logger:
self.logger.info(f"User {username} signed document for {form_name} with document_id {document_id}")

return document


def approve_document(self, form_name:str, json_data, metadata={}):
Expand Down

0 comments on commit ebd0d9d

Please sign in to comment.