From 69e375ab616157d11d3bdc84399ada56a9b43ff5 Mon Sep 17 00:00:00 2001 From: kompotkot Date: Wed, 4 Oct 2023 11:27:40 +0000 Subject: [PATCH 1/7] tx_hash for call_requests field --- .../7191eb70e99e_tx_hash_for_call_requests.py | 30 +++++++++++++++++++ engineapi/engineapi/contracts_actions.py | 1 + engineapi/engineapi/data.py | 1 + engineapi/engineapi/models.py | 1 + 4 files changed, 33 insertions(+) create mode 100644 engineapi/alembic/versions/7191eb70e99e_tx_hash_for_call_requests.py diff --git a/engineapi/alembic/versions/7191eb70e99e_tx_hash_for_call_requests.py b/engineapi/alembic/versions/7191eb70e99e_tx_hash_for_call_requests.py new file mode 100644 index 00000000..fc9a1e42 --- /dev/null +++ b/engineapi/alembic/versions/7191eb70e99e_tx_hash_for_call_requests.py @@ -0,0 +1,30 @@ +"""Tx hash for call requests + +Revision ID: 7191eb70e99e +Revises: 4f05d212ea49 +Create Date: 2023-10-04 11:23:12.516797 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7191eb70e99e' +down_revision = '4f05d212ea49' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('call_requests', sa.Column('tx_hash', sa.VARCHAR(length=256), nullable=True)) + op.create_unique_constraint(op.f('uq_call_requests_tx_hash'), 'call_requests', ['tx_hash']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('uq_call_requests_tx_hash'), 'call_requests', type_='unique') + op.drop_column('call_requests', 'tx_hash') + # ### end Alembic commands ### diff --git a/engineapi/engineapi/contracts_actions.py b/engineapi/engineapi/contracts_actions.py index 6cab6912..3d9e01d0 100644 --- a/engineapi/engineapi/contracts_actions.py +++ b/engineapi/engineapi/contracts_actions.py @@ -100,6 +100,7 @@ def parse_call_request_response( method=obj[0].method, request_id=str(obj[0].request_id), parameters=obj[0].parameters, + tx_hash=obj[0].tx_hash, expires_at=obj[0].expires_at, live_at=obj[0].live_at, created_at=obj[0].created_at, diff --git a/engineapi/engineapi/data.py b/engineapi/engineapi/data.py index 5fa20255..dd601efe 100644 --- a/engineapi/engineapi/data.py +++ b/engineapi/engineapi/data.py @@ -306,6 +306,7 @@ class CallRequestResponse(BaseModel): method: str request_id: str parameters: Dict[str, Any] + tx_hash: Optional[str] = None expires_at: Optional[datetime] = None live_at: datetime created_at: datetime diff --git a/engineapi/engineapi/models.py b/engineapi/engineapi/models.py index 10d2e357..f763fea9 100644 --- a/engineapi/engineapi/models.py +++ b/engineapi/engineapi/models.py @@ -313,6 +313,7 @@ class CallRequest(Base): method = Column(String, nullable=False, index=True) request_id = Column(DECIMAL, nullable=False, index=True) parameters = Column(JSONB, nullable=False) + tx_hash = Column(VARCHAR(256), unique=True, nullable=True) expires_at = Column(DateTime(timezone=True), nullable=True, index=True) live_at = Column(DateTime(timezone=True), server_default=utcnow(), nullable=False) From 74f956ff649bce45635a8bf2178cba280dc93c88 Mon Sep 17 00:00:00 2001 From: kompotkot Date: Mon, 16 Oct 2023 10:31:54 +0000 Subject: [PATCH 2/7] Modified authorize and verify workflows to support 2 message types --- engineapi/engineapi/auth.py | 203 +++++++++++++++++++++++------- engineapi/engineapi/middleware.py | 74 ++++++++++- engineapi/mypy.ini | 4 + 3 files changed, 227 insertions(+), 54 deletions(-) create mode 100644 engineapi/mypy.ini diff --git a/engineapi/engineapi/auth.py b/engineapi/engineapi/auth.py index 2b17b2d1..4c5cab5e 100644 --- a/engineapi/engineapi/auth.py +++ b/engineapi/engineapi/auth.py @@ -19,23 +19,17 @@ import argparse import base64 import json import time -from typing import Any, cast, Dict +from typing import Any, Dict, Optional, cast +import eth_keys from eip712.messages import EIP712Message, _hash_eip191_message from eth_account import Account from eth_account._utils.signing import sign_message_hash -import eth_keys +from eth_typing import ChecksumAddress from hexbytes import HexBytes from web3 import Web3 -AUTH_PAYLOAD_NAME = "MoonstreamAuthorization" -AUTH_VERSION = "1" - -# By default, authorizations will remain active for 24 hours. -DEFAULT_INTERVAL = 60 * 60 * 24 - - class MoonstreamAuthorizationVerificationError(Exception): """ Raised when invalid signer is provided. @@ -48,12 +42,48 @@ class MoonstreamAuthorizationExpired(Exception): """ -class MoonstreamAuthorization(EIP712Message): - _name_: "string" - _version_: "string" +class MoonstreamAuthorizationStructureError(Exception): + """ + Raised when signature has incorrect structure. + """ - address: "address" - deadline: "uint256" + +class MoonstreamAuthorization(EIP712Message): + _name_: "string" # type: ignore + _version_: "string" # type: ignore + + address: "address" # type: ignore + deadline: "uint256" # type: ignore + + +class MetaTXAuthorization(EIP712Message): + _name_: "string" # type: ignore + _version_: "string" # type: ignore + + caller: "address" # type: ignore + expires_at: "uint256" # type: ignore + + +EIP712_AUTHORIZATION_TYPES = { + "MoonstreamAuthorization": { + "name": "MoonstreamAuthorization", + "version": "1", + "eip712_message_class": MoonstreamAuthorization, + "primary_types": [ + {"name": "address", "type": "address"}, + {"name": "deadline", "type": int}, + ], + }, + "MetaTXAuthorization": { + "name": "MetaTXAuthorization", + "version": "1", + "eip712_message_class": MetaTXAuthorization, + "primary_types": [ + {"name": "caller", "type": "address"}, + {"name": "expires_at", "type": int}, + ], + }, +} def sign_message(message_hash_bytes: HexBytes, private_key: HexBytes) -> HexBytes: @@ -64,52 +94,77 @@ def sign_message(message_hash_bytes: HexBytes, private_key: HexBytes) -> HexByte return signed_message_bytes -def authorize(deadline: int, address: str, private_key: HexBytes) -> Dict[str, Any]: - message = MoonstreamAuthorization( - _name_=AUTH_PAYLOAD_NAME, - _version_=AUTH_VERSION, - address=address, - deadline=deadline, - ) +def authorize( + authorization_type: Dict[str, Any], + primary_types: Dict[str, Any], + private_key: HexBytes, + signature_name_output: str, +) -> Dict[str, Any]: + # Initializing instance of EIP712Message class + attrs: Dict[str, Any] = { + "_name_": authorization_type["name"], + "_version_": authorization_type["version"], + } + attrs.update(primary_types) + message = authorization_type["eip712_message_class"](**attrs) + # Generating message hash and signature msg_hash_bytes = HexBytes(_hash_eip191_message(message.signable_message)) signed_message = sign_message(msg_hash_bytes, private_key) - api_payload: Dict[str, Any] = { - "address": address, - "deadline": deadline, - "signed_message": signed_message.hex(), - } + api_payload: Dict[str, Any] = {signature_name_output: signed_message.hex()} + api_payload.update(primary_types) return api_payload -def verify(authorization_payload: Dict[str, Any]) -> bool: +def verify( + authorization_type: Dict[str, Any], + authorization_payload: Dict[str, Any], + signature_name_input: str, +) -> bool: """ Verifies provided signature signer by correct address. + + **Important** Assume that not address field is timefield (live_at, expires_at, deadline, etc) """ + # Initializing instance of EIP712Message class + attrs: Dict[str, Any] = { + "_name_": authorization_type["name"], + "_version_": authorization_type["version"], + } + time_now = int(time.time()) web3_client = Web3() - address = Web3.toChecksumAddress(cast(str, authorization_payload["address"])) - deadline = cast(int, authorization_payload["deadline"]) - signature = cast(str, authorization_payload["signed_message"]) - message = MoonstreamAuthorization( - _name_=AUTH_PAYLOAD_NAME, - _version_=AUTH_VERSION, - address=address, - deadline=deadline, - ) + address: Optional[ChecksumAddress] = None + time_field: Optional[int] = None + for pt in authorization_type["primary_types"]: + pt_name = pt["name"] + pt_type = pt["type"] + if pt_type == "address": + address = Web3.toChecksumAddress(cast(str, authorization_payload[pt_name])) + attrs[pt_name] = address + else: + time_field = cast(pt_type, authorization_payload[pt_name]) + attrs[pt_name] = time_field + if address is None or time_field is None: + raise MoonstreamAuthorizationStructureError( + "Field address or time_field could not be None" + ) + + message = authorization_type["eip712_message_class"](**attrs) + signature = cast(str, authorization_payload[signature_name_input]) signer_address = web3_client.eth.account.recover_message( message.signable_message, signature=signature ) if signer_address != address: raise MoonstreamAuthorizationVerificationError("Invalid signer") - if deadline < time_now: - raise MoonstreamAuthorizationExpired("Deadline exceeded") + if time_field < time_now: + raise MoonstreamAuthorizationExpired("Time field exceeded") return True @@ -121,15 +176,37 @@ def decrypt_keystore(keystore_path: str, password: str) -> HexBytes: def handle_authorize(args: argparse.Namespace) -> None: - address, private_key = decrypt_keystore(args.signer, args.password) - authorization = authorize(args.deadline, address, private_key) + if args.authorization_type not in EIP712_AUTHORIZATION_TYPES: + raise Exception("Provided unsupported EIP712 Authorization type") + + authorization_type = EIP712_AUTHORIZATION_TYPES[args.authorization_type] + primary_types = json.loads(args.primary_types) + for ptk in authorization_type["primary_types"]: + if ptk["name"] not in primary_types: + raise Exception(f"Lost primary type: {ptk}") + + _, private_key = decrypt_keystore(args.signer, args.password) + authorization = authorize( + authorization_type=authorization_type, + primary_types=primary_types, + private_key=private_key, + signature_name_output=args.signature_name_output, + ) print(json.dumps(authorization)) def handle_verify(args: argparse.Namespace) -> None: + if args.authorization_type not in EIP712_AUTHORIZATION_TYPES: + raise Exception("Provided unsupported EIP712 Authorization type") + + authorization_type = EIP712_AUTHORIZATION_TYPES[args.authorization_type] payload_json = base64.decodebytes(args.payload).decode("utf-8") payload = json.loads(payload_json) - verify(payload) + verify( + authorization_type=authorization_type, + authorization_payload=payload, + signature_name_input=args.signature_name_input, + ) print("Verified!") @@ -140,13 +217,6 @@ def generate_cli() -> argparse.ArgumentParser: subcommands = parser.add_subparsers() authorize_parser = subcommands.add_parser("authorize") - authorize_parser.add_argument( - "-t", - "--deadline", - type=int, - default=int(time.time()) + DEFAULT_INTERVAL, - help="Authorization deadline (seconds since epoch timestamp).", - ) authorize_parser.add_argument( "-s", "--signer", @@ -159,6 +229,30 @@ def generate_cli() -> argparse.ArgumentParser: required=False, help="(Optional) password for signing account. If you don't provide it here, you will be prompte for it.", ) + authorize_parser.add_argument( + "-t", + "--authorization-type", + required=True, + choices=[k for k in EIP712_AUTHORIZATION_TYPES.keys()], + help="One of supported EIP712 Message authorization types", + ) + authorize_parser.add_argument( + "--primary-types", + required=True, + help="Primary types for specified EIP712 Message authorization in JSON format {0}. Available keys: {1}".format( + {"name_1": "value", "name_2": "value"}, + [ + f"{v['primary_types']} for {k}" + for k, v in EIP712_AUTHORIZATION_TYPES.items() + ], + ), + ) + authorize_parser.add_argument( + "--signature-name-output", + type=str, + default="signed_message", + help="Key in output dictionary of signature", + ) authorize_parser.set_defaults(func=handle_authorize) verify_parser = subcommands.add_parser("verify") @@ -168,6 +262,19 @@ def generate_cli() -> argparse.ArgumentParser: required=True, help="Base64-encoded payload to verify", ) + verify_parser.add_argument( + "-t", + "--authorization-type", + required=True, + choices=[k for k in EIP712_AUTHORIZATION_TYPES.keys()], + help="One of supported EIP712 Message authorization types", + ) + verify_parser.add_argument( + "--signature-name-input", + type=str, + default="signed_message", + help="Key for signature in payload", + ) verify_parser.set_defaults(func=handle_verify) return parser diff --git a/engineapi/engineapi/middleware.py b/engineapi/engineapi/middleware.py index 5986b417..b9d95a1b 100644 --- a/engineapi/engineapi/middleware.py +++ b/engineapi/engineapi/middleware.py @@ -6,18 +6,23 @@ from uuid import UUID from bugout.data import BugoutResource, BugoutResources, BugoutUser from bugout.exceptions import BugoutResponseException +from eip712.messages import EIP712Message, _hash_eip191_message +from eth_account.messages import encode_defunct from fastapi import Header, HTTPException, Request, Response +from hexbytes import HexBytes from pydantic import AnyHttpUrl, parse_obj_as -from starlette.datastructures import Headers from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.cors import CORSMiddleware from starlette.responses import Response from starlette.types import ASGIApp from web3 import Web3 +from web3.auto import w3 as w3_auto from . import data from .auth import ( + EIP712_AUTHORIZATION_TYPES, MoonstreamAuthorizationExpired, + MoonstreamAuthorizationStructureError, MoonstreamAuthorizationVerificationError, verify, ) @@ -93,7 +98,7 @@ async def user_for_auth_header( status_code=403, detail="Wrong authorization header" ) except Exception as e: - logger.error(f"Error processing Brood response: {str(e)}") + logger.error(f"Error parsing auth header: {str(e)}") raise EngineHTTPException(status_code=500, detail="Internal server error") if user_token != "": @@ -118,6 +123,57 @@ async def user_for_auth_header( return user +async def metatx_sign_header( + authorization: str = Header(None), +) -> Optional[Dict[str, Any]]: + message: Optional[Dict[str, Any]] = None + if authorization is not None: + try: + auth_format, user_token = parse_auth_header(auth_header=authorization) + except InvalidAuthHeaderFormat: + raise EngineHTTPException( + status_code=403, detail="Wrong authorization header" + ) + except Exception as e: + logger.error(f"Error parsing auth header: {str(e)}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + if auth_format != "metatx": + raise EngineHTTPException( + status_code=403, + detail=f"Wrong authorization header format: {auth_format}", + ) + + try: + json_payload_str = base64.b64decode(user_token).decode("utf-8") + payload = json.loads(json_payload_str) + verify( + authorization_type=EIP712_AUTHORIZATION_TYPES["MetaTXAuthorization"], + authorization_payload=payload, + signature_name_input="signature", + ) + message = { + "caller": Web3.toChecksumAddress(payload.get("caller")), + "expires_at": payload.get("expires_at"), + } + except MoonstreamAuthorizationVerificationError as e: + logger.info("MetaTX authorization verification error: %s", e) + raise EngineHTTPException(status_code=403, detail="Invalid signer") + except MoonstreamAuthorizationExpired as e: + logger.info("MetaTX authorization expired: %s", e) + raise EngineHTTPException(status_code=403, detail="Authorization expired") + except MoonstreamAuthorizationStructureError as e: + logger.info("MetaTX authorization incorrect structure error: %s", e) + raise EngineHTTPException( + status_code=403, detail="Incorrect signature structure" + ) + except Exception as e: + logger.error("Unexpected exception: %s", e) + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return message + + class BroodAuthMiddleware(BaseHTTPMiddleware): """ Checks the authorization header on the request. If it represents a verified Brood user, @@ -155,7 +211,7 @@ class BroodAuthMiddleware(BaseHTTPMiddleware): except InvalidAuthHeaderFormat: return Response(status_code=403, content="Wrong authorization header") except Exception as e: - logger.error(f"Error processing Brood response: {str(e)}") + logger.error(f"Error parsing auth header: {str(e)}") return Response(status_code=500, content="Internal server error") try: @@ -226,9 +282,15 @@ class EngineAuthMiddleware(BaseHTTPMiddleware): authorization_header_components[-1] ).decode("utf-8") - json_payload = json.loads(json_payload_str) - verified = verify(json_payload) - address = json_payload.get("address") + payload = json.loads(json_payload_str) + verified = verify( + authorization_type=EIP712_AUTHORIZATION_TYPES[ + "MoonstreamAuthorization" + ], + authorization_payload=payload, + signature_name_input="signed_message", + ) + address = payload.get("address") if address is not None: address = Web3.toChecksumAddress(address) else: diff --git a/engineapi/mypy.ini b/engineapi/mypy.ini new file mode 100644 index 00000000..c4a4c5c1 --- /dev/null +++ b/engineapi/mypy.ini @@ -0,0 +1,4 @@ +[mypy] + +[mypy-eth_keys.*] +ignore_missing_imports = True From 0ed9ad2eccf4962365227093940ba22d9111ccfb Mon Sep 17 00:00:00 2001 From: kompotkot Date: Mon, 16 Oct 2023 11:54:39 +0000 Subject: [PATCH 3/7] Complete call_request endpoint --- engineapi/engineapi/contracts_actions.py | 38 ++++++++++++++++++++++++ engineapi/engineapi/routes/metatx.py | 33 +++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/engineapi/engineapi/contracts_actions.py b/engineapi/engineapi/contracts_actions.py index 3d9e01d0..15f46ccc 100644 --- a/engineapi/engineapi/contracts_actions.py +++ b/engineapi/engineapi/contracts_actions.py @@ -577,6 +577,44 @@ def delete_requests( return requests_to_delete_num +def complete_call_request( + db_session: Session, + tx_hash: str, + call_request_id: uuid.UUID, +) -> CallRequest: + results = ( + db_session.query(CallRequest, RegisteredContract) + .join( + RegisteredContract, + CallRequest.registered_contract_id == RegisteredContract.id, + ) + .filter(CallRequest.id == call_request_id) + .all() + ) + + if len(results) == 0: + raise CallRequestNotFound("Call request with given ID not found") + elif len(results) != 1: + raise Exception( + f"Incorrect number of results found for request_id {call_request_id}" + ) + call_request, registered_contract = results[0] + + call_request.tx_hash = tx_hash + + try: + db_session.add(call_request) + db_session.commit() + except Exception as err: + logger.error( + f"complete_call_request -- error updating in database: {repr(err)}" + ) + db_session.rollback() + raise + + return (call_request, registered_contract) + + def handle_register(args: argparse.Namespace) -> None: """ Handles the register command. diff --git a/engineapi/engineapi/routes/metatx.py b/engineapi/engineapi/routes/metatx.py index 4ac03a4d..913bf2b5 100644 --- a/engineapi/engineapi/routes/metatx.py +++ b/engineapi/engineapi/routes/metatx.py @@ -10,7 +10,7 @@ from typing import Dict, List, Optional from uuid import UUID from bugout.data import BugoutUser -from fastapi import Body, Depends, FastAPI, Path, Query, Request +from fastapi import Body, Depends, FastAPI, Form, Path, Query, Request from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import Session @@ -19,6 +19,7 @@ from ..middleware import ( BroodAuthMiddleware, BugoutCORSMiddleware, EngineHTTPException, + metatx_sign_header, user_for_auth_header, ) from ..settings import DOCS_TARGET_PATH @@ -47,6 +48,7 @@ whitelist_paths = { "/metatx/contracts/types": "GET", "/metatx/requests/types": "GET", "/metatx/requests": "GET", # Controls by custom authentication check + "/metatx/requests/complete": "POST", # Controls by metatx authentication check } app = FastAPI( @@ -429,3 +431,32 @@ async def delete_requests( raise EngineHTTPException(status_code=500) return deleted_requests + + +# @app.post("/requests/{request_id}/complete", tags=["requests"]) +@app.post("/requests/complete", tags=["requests"]) +async def complete_call_request_route( + tx_hash: str = Form(...), + call_request_id: UUID = Form(...), + message=Depends(metatx_sign_header), + db_session: Session = Depends(db.yield_db_session), +): + """ + Set tx hash for specified call_request by verified account. + """ + try: + request = contracts_actions.complete_call_request( + db_session=db_session, + tx_hash=tx_hash, + call_request_id=call_request_id, + ) + except contracts_actions.CallRequestNotFound: + raise EngineHTTPException( + status_code=404, + detail="There is no call request with that ID.", + ) + except Exception as e: + logger.error(repr(e)) + raise EngineHTTPException(status_code=500) + + return contracts_actions.parse_call_request_response(request) From e9c46f7a5beb58f3d9aae9e0247664e67c9da16d Mon Sep 17 00:00:00 2001 From: kompotkot Date: Tue, 7 Nov 2023 11:04:05 +0000 Subject: [PATCH 4/7] Compare with caller from message --- engineapi/engineapi/contracts_actions.py | 2 ++ engineapi/engineapi/routes/metatx.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/engineapi/engineapi/contracts_actions.py b/engineapi/engineapi/contracts_actions.py index 15f46ccc..f3378410 100644 --- a/engineapi/engineapi/contracts_actions.py +++ b/engineapi/engineapi/contracts_actions.py @@ -581,6 +581,7 @@ def complete_call_request( db_session: Session, tx_hash: str, call_request_id: uuid.UUID, + caller: str, ) -> CallRequest: results = ( db_session.query(CallRequest, RegisteredContract) @@ -589,6 +590,7 @@ def complete_call_request( CallRequest.registered_contract_id == RegisteredContract.id, ) .filter(CallRequest.id == call_request_id) + .filter(CallRequest.caller == caller) .all() ) diff --git a/engineapi/engineapi/routes/metatx.py b/engineapi/engineapi/routes/metatx.py index 913bf2b5..dd1ef148 100644 --- a/engineapi/engineapi/routes/metatx.py +++ b/engineapi/engineapi/routes/metatx.py @@ -48,7 +48,7 @@ whitelist_paths = { "/metatx/contracts/types": "GET", "/metatx/requests/types": "GET", "/metatx/requests": "GET", # Controls by custom authentication check - "/metatx/requests/complete": "POST", # Controls by metatx authentication check + "/metatx/requests/complete": "POST", # Controls by metatx authentication check } app = FastAPI( @@ -449,6 +449,7 @@ async def complete_call_request_route( db_session=db_session, tx_hash=tx_hash, call_request_id=call_request_id, + caller=message["caller"], ) except contracts_actions.CallRequestNotFound: raise EngineHTTPException( From 065ff0347646189c4b222038eb041ebe81c8273a Mon Sep 17 00:00:00 2001 From: kompotkot Date: Thu, 7 Dec 2023 15:04:16 +0000 Subject: [PATCH 5/7] Switched middleware to depends oauth2_scheme in metatx --- .../7191eb70e99e_tx_hash_for_call_requests.py | 4 +- engineapi/engineapi/middleware.py | 68 +++++++++++++------ engineapi/engineapi/routes/metatx.py | 55 ++++++--------- 3 files changed, 69 insertions(+), 58 deletions(-) diff --git a/engineapi/alembic/versions/7191eb70e99e_tx_hash_for_call_requests.py b/engineapi/alembic/versions/7191eb70e99e_tx_hash_for_call_requests.py index fc9a1e42..c5b77749 100644 --- a/engineapi/alembic/versions/7191eb70e99e_tx_hash_for_call_requests.py +++ b/engineapi/alembic/versions/7191eb70e99e_tx_hash_for_call_requests.py @@ -1,7 +1,7 @@ """Tx hash for call requests Revision ID: 7191eb70e99e -Revises: 4f05d212ea49 +Revises: 6d07739cb13e Create Date: 2023-10-04 11:23:12.516797 """ @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '7191eb70e99e' -down_revision = '4f05d212ea49' +down_revision = '6d07739cb13e' branch_labels = None depends_on = None diff --git a/engineapi/engineapi/middleware.py b/engineapi/engineapi/middleware.py index b9d95a1b..5868f336 100644 --- a/engineapi/engineapi/middleware.py +++ b/engineapi/engineapi/middleware.py @@ -8,7 +8,8 @@ from bugout.data import BugoutResource, BugoutResources, BugoutUser from bugout.exceptions import BugoutResponseException from eip712.messages import EIP712Message, _hash_eip191_message from eth_account.messages import encode_defunct -from fastapi import Header, HTTPException, Request, Response +from fastapi import Depends, Header, HTTPException, Request, Response +from fastapi.security import OAuth2PasswordBearer from hexbytes import HexBytes from pydantic import AnyHttpUrl, parse_obj_as from starlette.middleware.base import BaseHTTPMiddleware @@ -39,6 +40,8 @@ from .settings import bugout_client as bc logger = logging.getLogger(__name__) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + class InvalidAuthHeaderFormat(Exception): """ @@ -82,7 +85,44 @@ def bugout_auth(token: str) -> BugoutUser: return user -async def user_for_auth_header( +def brood_auth(token: UUID) -> BugoutUser: + try: + user: BugoutUser = bugout_auth(token=token) + except BugoutUnverifiedAuth: + logger.info(f"Attempted access by unverified Brood account: {user.id}") + raise EngineHTTPException( + status_code=403, + detail="Only verified accounts can have access", + ) + except BugoutAuthWrongApp: + raise EngineHTTPException( + status_code=403, + detail="User does not belong to this application", + ) + except BugoutResponseException as e: + raise EngineHTTPException( + status_code=e.status_code, + detail=e.detail, + ) + except Exception as e: + logger.error(f"Error processing Brood response: {str(e)}") + raise EngineHTTPException( + status_code=500, + detail="Internal server error", + ) + + return user + + +async def request_user_auth( + token: UUID = Depends(oauth2_scheme), +) -> BugoutUser: + user = brood_auth(token=token) + + return user + + +async def request_none_or_user_auth( authorization: str = Header(None), ) -> Optional[BugoutUser]: """ @@ -90,9 +130,9 @@ async def user_for_auth_header( """ user: Optional[BugoutUser] = None if authorization is not None: - user_token: str = "" + token: str = "" try: - _, user_token = parse_auth_header(auth_header=authorization) + _, token = parse_auth_header(auth_header=authorization) except InvalidAuthHeaderFormat: raise EngineHTTPException( status_code=403, detail="Wrong authorization header" @@ -101,24 +141,8 @@ async def user_for_auth_header( logger.error(f"Error parsing auth header: {str(e)}") raise EngineHTTPException(status_code=500, detail="Internal server error") - if user_token != "": - try: - user: BugoutUser = bugout_auth(token=user_token) - except BugoutUnverifiedAuth: - logger.info(f"Attempted access by unverified Brood account: {user.id}") - raise EngineHTTPException( - status_code=403, - detail="Only verified accounts can have access", - ) - except BugoutAuthWrongApp: - raise EngineHTTPException( - status_code=403, detail="User does not belong to this application" - ) - except BugoutResponseException as e: - raise HTTPException(status_code=e.status_code, detail=e.detail) - except Exception as e: - logger.error(f"Error processing Brood response: {str(e)}") - raise HTTPException(status_code=500, detail="Internal server error") + if token != "": + user = brood_auth(token=token) return user diff --git a/engineapi/engineapi/routes/metatx.py b/engineapi/engineapi/routes/metatx.py index dd1ef148..8c9b8a6f 100644 --- a/engineapi/engineapi/routes/metatx.py +++ b/engineapi/engineapi/routes/metatx.py @@ -16,11 +16,11 @@ from sqlalchemy.orm import Session from .. import contracts_actions, data, db from ..middleware import ( - BroodAuthMiddleware, BugoutCORSMiddleware, EngineHTTPException, metatx_sign_header, - user_for_auth_header, + request_none_or_user_auth, + request_user_auth, ) from ..settings import DOCS_TARGET_PATH from ..version import VERSION @@ -41,16 +41,6 @@ tags_metadata = [ ] -whitelist_paths = { - "/metatx/openapi.json": "GET", - f"/metatx/{DOCS_TARGET_PATH}": "GET", - "/metatx/blockchains": "GET", - "/metatx/contracts/types": "GET", - "/metatx/requests/types": "GET", - "/metatx/requests": "GET", # Controls by custom authentication check - "/metatx/requests/complete": "POST", # Controls by metatx authentication check -} - app = FastAPI( title=TITLE, description=DESCRIPTION, @@ -61,9 +51,6 @@ app = FastAPI( redoc_url=f"/{DOCS_TARGET_PATH}", ) - -app.add_middleware(BroodAuthMiddleware, whitelist=whitelist_paths) - app.add_middleware( BugoutCORSMiddleware, allow_credentials=True, @@ -97,11 +84,11 @@ async def blockchains_route( response_model=List[data.RegisteredContractResponse], ) async def list_registered_contracts_route( - request: Request, blockchain: Optional[str] = Query(None), address: Optional[str] = Query(None), limit: int = Query(10), offset: Optional[int] = Query(None), + user: BugoutUser = Depends(request_user_auth), db_session: Session = Depends(db.yield_db_read_only_session), ) -> List[data.RegisteredContractResponse]: """ @@ -111,7 +98,7 @@ async def list_registered_contracts_route( registered_contracts_with_blockchain = ( contracts_actions.lookup_registered_contracts( db_session=db_session, - metatx_requester_id=request.state.user.id, + metatx_requester_id=user.id, blockchain=blockchain, address=address, limit=limit, @@ -134,8 +121,8 @@ async def list_registered_contracts_route( response_model=data.RegisteredContractResponse, ) async def get_registered_contract_route( - request: Request, contract_id: UUID = Path(...), + user: BugoutUser = Depends(request_user_auth), db_session: Session = Depends(db.yield_db_read_only_session), ) -> List[data.RegisteredContractResponse]: """ @@ -144,7 +131,7 @@ async def get_registered_contract_route( try: contract_with_blockchain = contracts_actions.get_registered_contract( db_session=db_session, - metatx_requester_id=request.state.user.id, + metatx_requester_id=user.id, contract_id=contract_id, ) except NoResultFound: @@ -165,8 +152,8 @@ async def get_registered_contract_route( "/contracts", tags=["contracts"], response_model=data.RegisteredContractResponse ) async def register_contract_route( - request: Request, contract: data.RegisterContractRequest = Body(...), + user: BugoutUser = Depends(request_user_auth), db_session: Session = Depends(db.yield_db_session), ) -> data.RegisteredContractResponse: """ @@ -175,7 +162,7 @@ async def register_contract_route( try: contract_with_blockchain = contracts_actions.register_contract( db_session=db_session, - metatx_requester_id=request.state.user.id, + metatx_requester_id=user.id, blockchain_name=contract.blockchain, address=contract.address, title=contract.title, @@ -206,15 +193,15 @@ async def register_contract_route( response_model=data.RegisteredContractResponse, ) async def update_contract_route( - request: Request, contract_id: UUID = Path(...), update_info: data.UpdateContractRequest = Body(...), + user: BugoutUser = Depends(request_user_auth), db_session: Session = Depends(db.yield_db_session), ) -> data.RegisteredContractResponse: try: contract_with_blockchain = contracts_actions.update_registered_contract( db_session=db_session, - metatx_requester_id=request.state.user.id, + metatx_requester_id=user.id, contract_id=contract_id, title=update_info.title, description=update_info.description, @@ -241,8 +228,8 @@ async def update_contract_route( response_model=data.RegisteredContractResponse, ) async def delete_contract_route( - request: Request, contract_id: UUID = Path(...), + user: BugoutUser = Depends(request_user_auth), db_session: Session = Depends(db.yield_db_session), ) -> data.RegisteredContractResponse: """ @@ -251,7 +238,7 @@ async def delete_contract_route( try: deleted_contract_with_blockchain = contracts_actions.delete_registered_contract( db_session=db_session, - metatx_requester_id=request.state.user.id, + metatx_requester_id=user.id, registered_contract_id=contract_id, ) except Exception as err: @@ -299,7 +286,7 @@ async def list_requests_route( offset: Optional[int] = Query(None), show_expired: bool = Query(False), show_before_live_at: bool = Query(False), - user: Optional[BugoutUser] = Depends(user_for_auth_header), + user: Optional[BugoutUser] = Depends(request_none_or_user_auth), db_session: Session = Depends(db.yield_db_read_only_session), ) -> List[data.CallRequestResponse]: """ @@ -334,6 +321,7 @@ async def list_requests_route( ) async def get_request( request_id: UUID = Path(...), + _: BugoutUser = Depends(request_user_auth), db_session: Session = Depends(db.yield_db_read_only_session), ) -> List[data.CallRequestResponse]: """ @@ -360,8 +348,8 @@ async def get_request( @app.post("/requests", tags=["requests"], response_model=int) async def create_requests( - request: Request, data: data.CreateCallRequestsAPIRequest = Body(...), + user: BugoutUser = Depends(request_user_auth), db_session: Session = Depends(db.yield_db_session), ) -> int: """ @@ -372,7 +360,7 @@ async def create_requests( try: num_requests = contracts_actions.create_request_calls( db_session=db_session, - metatx_requester_id=request.state.user.id, + metatx_requester_id=user.id, registered_contract_id=data.contract_id, contract_address=data.contract_address, call_specs=data.specifications, @@ -413,8 +401,8 @@ async def create_requests( @app.delete("/requests", tags=["requests"], response_model=int) async def delete_requests( - request: Request, request_ids: List[UUID] = Body(...), + user: BugoutUser = Depends(request_user_auth), db_session: Session = Depends(db.yield_db_session), ) -> int: """ @@ -423,7 +411,7 @@ async def delete_requests( try: deleted_requests = contracts_actions.delete_requests( db_session=db_session, - metatx_requester_id=request.state.user.id, + metatx_requester_id=user.id, request_ids=request_ids, ) except Exception as err: @@ -433,11 +421,10 @@ async def delete_requests( return deleted_requests -# @app.post("/requests/{request_id}/complete", tags=["requests"]) -@app.post("/requests/complete", tags=["requests"]) +@app.post("/requests/{request_id}/complete", tags=["requests"]) async def complete_call_request_route( tx_hash: str = Form(...), - call_request_id: UUID = Form(...), + request_id: UUID = Path(...), message=Depends(metatx_sign_header), db_session: Session = Depends(db.yield_db_session), ): @@ -448,7 +435,7 @@ async def complete_call_request_route( request = contracts_actions.complete_call_request( db_session=db_session, tx_hash=tx_hash, - call_request_id=call_request_id, + call_request_id=request_id, caller=message["caller"], ) except contracts_actions.CallRequestNotFound: From 12bcd84eb1a707c1fe96c3cdf2eb15edfd7d0347 Mon Sep 17 00:00:00 2001 From: kompotkot Date: Thu, 7 Dec 2023 16:47:03 +0000 Subject: [PATCH 6/7] Body for complete request route --- engineapi/engineapi/data.py | 4 ++++ engineapi/engineapi/middleware.py | 2 +- engineapi/engineapi/routes/metatx.py | 8 ++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/engineapi/engineapi/data.py b/engineapi/engineapi/data.py index c1b01beb..dbec6a8d 100644 --- a/engineapi/engineapi/data.py +++ b/engineapi/engineapi/data.py @@ -329,6 +329,10 @@ class CallRequestResponse(BaseModel): return Web3.toChecksumAddress(v) +class CompleteCallRequestsAPIRequest(BaseModel): + tx_hash: str + + class QuartilesResponse(BaseModel): percentile_25: Dict[str, Any] percentile_50: Dict[str, Any] diff --git a/engineapi/engineapi/middleware.py b/engineapi/engineapi/middleware.py index 5868f336..395ee332 100644 --- a/engineapi/engineapi/middleware.py +++ b/engineapi/engineapi/middleware.py @@ -147,7 +147,7 @@ async def request_none_or_user_auth( return user -async def metatx_sign_header( +async def metatx_verify_header( authorization: str = Header(None), ) -> Optional[Dict[str, Any]]: message: Optional[Dict[str, Any]] = None diff --git a/engineapi/engineapi/routes/metatx.py b/engineapi/engineapi/routes/metatx.py index 8c9b8a6f..092540b7 100644 --- a/engineapi/engineapi/routes/metatx.py +++ b/engineapi/engineapi/routes/metatx.py @@ -18,7 +18,7 @@ from .. import contracts_actions, data, db from ..middleware import ( BugoutCORSMiddleware, EngineHTTPException, - metatx_sign_header, + metatx_verify_header, request_none_or_user_auth, request_user_auth, ) @@ -423,9 +423,9 @@ async def delete_requests( @app.post("/requests/{request_id}/complete", tags=["requests"]) async def complete_call_request_route( - tx_hash: str = Form(...), + complete_request: data.CompleteCallRequestsAPIRequest = Body(...), request_id: UUID = Path(...), - message=Depends(metatx_sign_header), + message=Depends(metatx_verify_header), db_session: Session = Depends(db.yield_db_session), ): """ @@ -434,7 +434,7 @@ async def complete_call_request_route( try: request = contracts_actions.complete_call_request( db_session=db_session, - tx_hash=tx_hash, + tx_hash=complete_request.tx_hash, call_request_id=request_id, caller=message["caller"], ) From f9a265b97ac36170feab25177c4ffa322e74f0a5 Mon Sep 17 00:00:00 2001 From: kompotkot Date: Tue, 12 Dec 2023 11:22:42 +0000 Subject: [PATCH 7/7] Live after query param --- engineapi/engineapi/contracts_actions.py | 12 +++++++----- engineapi/engineapi/routes/metatx.py | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/engineapi/engineapi/contracts_actions.py b/engineapi/engineapi/contracts_actions.py index 4d9f6ca8..ceed9a13 100644 --- a/engineapi/engineapi/contracts_actions.py +++ b/engineapi/engineapi/contracts_actions.py @@ -481,7 +481,7 @@ def list_call_requests( limit: int = 10, offset: Optional[int] = None, show_expired: bool = False, - show_before_live_at: bool = False, + live_after: Optional[int] = None, metatx_requester_id: Optional[uuid.UUID] = None, ) -> List[Row[Tuple[CallRequest, RegisteredContract, CallRequestType]]]: """ @@ -527,15 +527,17 @@ def list_call_requests( query = query.filter( CallRequest.metatx_requester_id == metatx_requester_id, ) - if not show_before_live_at: - query = query.filter( - or_(CallRequest.live_at < func.now(), CallRequest.live_at == None) - ) else: query = query.filter( or_(CallRequest.live_at < func.now(), CallRequest.live_at == None) ) + if live_after is not None: + assert live_after == int(live_after) + if live_after <= 0: + raise ValueError("live_after must be positive") + query = query.filter(CallRequest.live_at >= datetime.fromtimestamp(live_after)) + if offset is not None: query = query.offset(offset) diff --git a/engineapi/engineapi/routes/metatx.py b/engineapi/engineapi/routes/metatx.py index 092540b7..23a14b26 100644 --- a/engineapi/engineapi/routes/metatx.py +++ b/engineapi/engineapi/routes/metatx.py @@ -285,7 +285,7 @@ async def list_requests_route( limit: int = Query(100), offset: Optional[int] = Query(None), show_expired: bool = Query(False), - show_before_live_at: bool = Query(False), + live_after: Optional[int] = Query(None), user: Optional[BugoutUser] = Depends(request_none_or_user_auth), db_session: Session = Depends(db.yield_db_read_only_session), ) -> List[data.CallRequestResponse]: @@ -303,7 +303,7 @@ async def list_requests_route( limit=limit, offset=offset, show_expired=show_expired, - show_before_live_at=show_before_live_at, + live_after=live_after, metatx_requester_id=user.id if user is not None else None, ) except ValueError as e: