Commit 83cf44d1 authored by parciak's avatar parciak
Browse files

Merge branch 'parciak-add-apikey' into 'master'

Version 0.3.1

See merge request medinfpub/umg-medic/aw-agents/annotation-agent!3
parents 2374cf6d 06ae8ecc
Pipeline #156569 failed with stages
in 7 minutes and 22 seconds
This diff is collapsed.
......@@ -2,6 +2,6 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
VERSION = (0, 3, 0)
VERSION = (0, 3, 1)
__version__ = ".".join(map(str, VERSION))
......@@ -2,8 +2,10 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pydantic import BaseSettings
import os
from typing import List
from pydantic import BaseSettings
from annotator import __version__
......@@ -21,6 +23,7 @@ class BasicSettings(BaseSettings):
couch_pass: str = "medic2020"
couch_db: str = "medic"
static_directory: str = "annotator/static"
application_api_keys: List[str] = []
def get_version():
......
......@@ -2,6 +2,7 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
class ActiveWorkflowError(Exception):
def __init__(self, name: str, message: str):
self.name = name
......@@ -11,4 +12,10 @@ class ActiveWorkflowError(Exception):
class ConfigurationError(Exception):
def __init__(self, message: str):
self.name = "Agent Configuation Error"
self.message = message
class UnauthorizedException(Exception):
def __init__(self, message: str):
self.name = "Unauthorized"
self.message = message
\ No newline at end of file
......@@ -2,10 +2,9 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from annotator.errors import ActiveWorkflowError, ConfigurationError
import datetime
import os
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, Optional, Union
from annotator import access_log, error_log
from annotator import config
......@@ -20,14 +19,12 @@ import fastapi.responses
app = FastAPI()
# TODO: specify your endpoint name here!
endpoint_name = "annotator"
@app.get("/appinfo")
def appinfo():
""" Prints the configuration of this instance.
"""
"""Prints the configuration of this instance."""
return config.BasicSettings().dict()
......@@ -37,8 +34,8 @@ def appinfo():
response_model_exclude_none=True,
)
def aw_endpoint(payload: awmodels.RequestCommon, background_tasks: BackgroundTasks):
""" Implements an remote agent API endpoint as specified by active_workflow,
see [active_workflow docs](https://github.com/automaticmode/active_workflow/blob/master/docs/remote_agent_api.md)
"""Implements an remote agent API endpoint as specified by active_workflow,
see [active_workflow docs](https://github.com/automaticmode/active_workflow/blob/master/docs/remote_agent_api.md)
for more details. The call is forwarded to the correct endpoint.
"""
......@@ -52,7 +49,9 @@ def aw_endpoint(payload: awmodels.RequestCommon, background_tasks: BackgroundTas
)
elif payload.method is not None and payload.method == "check":
return check(payload=awmodels.RequestCheck.parse_obj(payload.dict()),)
return check(
payload=awmodels.RequestCheck.parse_obj(payload.dict()),
)
raise errors.ActiveWorkflowError(
name="Method Error",
......@@ -67,7 +66,7 @@ def aw_endpoint(payload: awmodels.RequestCommon, background_tasks: BackgroundTas
status_code=fastapi.status.HTTP_200_OK,
)
def register(payload: awmodels.RequestRegister):
""" Register according to the [ActiveWorkflow Remote Agent API](https://github.com/automaticmode/active_workflow/blob/master/docs/remote_agent_api.md#register-method).
"""Register according to the [ActiveWorkflow Remote Agent API](https://github.com/automaticmode/active_workflow/blob/master/docs/remote_agent_api.md#register-method).
Returns the description as well as the name and default options to the caller,
allowing ActiveWorkflow to register this agent into the engine.
......@@ -90,13 +89,17 @@ def register(payload: awmodels.RequestRegister):
status_code=fastapi.status.HTTP_202_ACCEPTED,
)
def receive(payload: awmodels.RequestReceive, background_tasks: BackgroundTasks):
""" Receives a message that will annotate a CDSTAR archive.
"""
"""Receives a message that will annotate a CDSTAR archive."""
access_log.info(
f"{datetime.datetime.now().isoformat()} - Receive: {payload.dict()}"
)
if not utils.is_authorized(payload.params):
raise errors.UnauthorizedException(
message="Please supply valid credentials using the `api_key` option parameter referring to a stored credentials key. You may use Liquid templating: `\"api_key\": \"{% credential credential_name %}\"`.",
)
response = awmodels.ResponseReceive()
response.result.memory.archives += payload.params.memory.archives
......@@ -232,11 +235,15 @@ def run_annotation(
status_code=fastapi.status.HTTP_200_OK,
)
def check(payload: awmodels.RequestCheck):
""" Checks on a running TOS Job or retrieves the output of a completed Job.
"""
"""Checks on a running TOS Job or retrieves the output of a completed Job."""
access_log.info(f"{datetime.datetime.now().isoformat()} - Check: {payload.dict()}")
if not utils.is_authorized(payload.params):
raise errors.UnauthorizedException(
message="Please supply valid credentials using the `api_key` option parameter referring to a stored credentials key. You may use Liquid templating: `\"api_key\": \"{% credential credential_name %}\"`.",
)
resp = awmodels.ResponseCheck()
for vault_id, archive_id in payload.params.memory.archives:
......@@ -311,3 +318,15 @@ async def aw_exception_handler(request: Request, exc: errors.ConfigurationError)
status_code=fastapi.status.HTTP_400_BAD_REQUEST,
content=resp.dict(exclude_none=True),
)
@app.exception_handler(errors.UnauthorizedException)
async def auth_exception_handler(request: Request, exc: errors.UnauthorizedException):
error_log.error(f"{exc.name}: {exc.message}")
resp = awmodels.ResponseCheck()
resp.result.errors.append(f"{exc.name}: {exc.message}")
return fastapi.responses.JSONResponse(
status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
content=resp.dict(exclude_none=True),
)
......@@ -17,6 +17,19 @@ from annotator import config
#
class CredentialsCommon(BaseModel):
"""
This model represents the expected credentials content to communicate credentials with the agent.
"""
name: str = Field("", example="")
value: str = Field("", example="")
@staticmethod
def example() -> dict:
return {"name": "", "value": ""}
class ParamsCommon(BaseModel, abc.ABC):
"""
These are all possible parameters that may be supplied using the Active Workflow Remote Agent API.
......@@ -25,7 +38,7 @@ class ParamsCommon(BaseModel, abc.ABC):
message: Optional[Dict[str, Any]] = {}
options: Optional[Dict[str, Any]] = {}
memory: Optional[Dict[str, Any]] = {}
credentials: Optional[List[Dict[str, str]]] = []
credentials: Optional[List[CredentialsCommon]] = []
class ResultCommon(BaseModel, abc.ABC):
......@@ -144,6 +157,7 @@ class OptionsCommon(BaseModel):
couch_uri: str = Field(None, example="http://lcoalhost:5984")
couch_user: str = Field(None, example="someuser")
couch_pass: str = Field(None, example="somepass")
api_key: str = Field("", example="")
annotations_archive: Dict[str, Any] = Field(
{}, example={"id": "something", "name": "Some Thing"}
)
......@@ -156,6 +170,7 @@ class OptionsCommon(BaseModel):
return {
"annotations_archive": {"id": "something", "name": "Some Thing"},
"annotations_file": {"id": "something", "name": "Some Thing"},
"api_key": "",
}
......@@ -164,7 +179,9 @@ class MemoryCommon(BaseModel):
This model represents the expected memory content to communicate state of the agent.
"""
archives: List[Tuple[str, str]] = Field([], example=[("medic", "a1b2c3d4e5f6")])
archives: List[Tuple[str, str]] = Field([], example=[(
"medic", "a1b2c3d4e5f6"
)])
@staticmethod
def example() -> dict:
......@@ -232,8 +249,8 @@ class ParamsReceive(ParamsCommon):
memory: MemoryCommon = Field(
MemoryCommon(), example=MemoryCommon(**MemoryCommon.example())
)
credentials: CredentialsCommon = Field(
CredentialsCommon(), example=CredentialsCommon(**CredentialsCommon.example())
credentials: List[CredentialsCommon] = Field(
[], example=[CredentialsCommon(**CredentialsCommon.example())]
)
......@@ -278,8 +295,8 @@ class ParamsCheck(ParamsCommon):
memory: MemoryCommon = Field(
MemoryCommon(), example=MemoryCommon(**MemoryCommon.example())
)
credentials: CredentialsCommon = Field(
CredentialsCommon(), example=CredentialsCommon(**CredentialsCommon.example())
credentials: List[CredentialsCommon] = Field(
[], example=[CredentialsCommon(**CredentialsCommon.example())]
)
......
......@@ -85,6 +85,9 @@ def upload_jsonld(
) as client:
database: CloudantDatabase.CouchDatabase = client[couch_db]
if not database.exists():
error_log.error(
f"CouchDB Database {couch_db} does not exists at {couch_uri}."
)
return False
jsonld["_id"] = jsonld["identifier"]
created_doc: CloudantDocument.Document = database.create_document(jsonld)
......
......@@ -10,9 +10,11 @@ import sys
import tempfile
import traceback
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
from annotator import config
from annotator import error_log
from annotator import models_activeworkflow as awmodels
class AnnotationState(str, enum.Enum):
......@@ -52,7 +54,7 @@ def merge_jsonld(jsonld: Dict[str, Any], annotations: Dict[str, Any]) -> Dict[st
def get_jsonld_reference(jsonld: Dict[str, Any]) -> Optional[Dict[str, Any]]:
""" Get a dict reference a JSON-LD dictionary.
"""Get a dict reference a JSON-LD dictionary.
Retrieves the `@type` and the `@id` key from the `jsonld` parameter, which should by a
dictionary representing a JSON-LD.
......@@ -60,9 +62,9 @@ def get_jsonld_reference(jsonld: Dict[str, Any]) -> Optional[Dict[str, Any]]:
Parameters
----------
jsonld: Dict[str, Any]
A dictionary representing a JSON-LD metadata document, containing the keys `@type` and
A dictionary representing a JSON-LD metadata document, containing the keys `@type` and
`@id` in order to retrieve a proper reference to it.
Returns
-------
Optional[Dict[str, Any]]
......@@ -123,4 +125,54 @@ def remove_metafile(path: str) -> bool:
except FileNotFoundError:
error_log.warning(f"File {path} could not be removed as it was not found.")
return True
return False
\ No newline at end of file
return False
def is_authorized(
params: Union[awmodels.ParamsRegister, awmodels.ParamsReceive, awmodels.ParamsCheck]
) -> bool:
"""
Check if a params section is authorized to use this Annotation agent instance.
This method will check 1) if credentials are set in the configuration of the agent and 2)
whether the supplied params are authorized to use the agent. If not credentials are set
(which is the default), the method will return True. In any other case, the `api_key`
option has to contain the credential name in Liquid Templating notation which refers to
the credentials parameter.
Parameters
----------
params: Union[awmodels.ParamsRegister, awmodels.ParamsReceive, awmodels.ParamsCheck]
Any params dictionary posted by the requester JSON
Results
-------
bool
True if parameters hold valid credentials, authorizing any request. False otherwise
"""
if len(config.BasicSettings().application_api_keys) == 0:
# If there are no credentials set, no credentials are needed.
return True
set_api_key = params.options.api_key
if set_api_key.startswith("{%") and set_api_key.endswith("%}"):
set_api_key = set_api_key[2:-2].strip()
if set_api_key.startswith("credential"):
search_credential_name = set_api_key[len("credential") :].strip()
else:
# If you end up here: an api key like `{% something %}` has been supplied. Either use
# the `credential` key word or use the plain API key, but do not mix.
return False
else:
if set_api_key.strip() in config.BasicSettings().application_api_keys:
return True
else:
return False
for credential in params.credentials:
if credential.name == search_credential_name:
if credential.value in config.BasicSettings().application_api_keys:
return True
return False
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment