Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GUNDI-3795: Improve authentication config #5

Merged
merged 22 commits into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
278020c
Add ExcecutableMixin.
chrisdoehring Nov 19, 2024
ce36740
Merge pull request #33 from PADAS/add-executable-mixin
chrisdoehring Nov 20, 2024
ede3938
Update README.md
chrisdoehring Nov 22, 2024
e87a5f5
Initial Release
vgarcia13 Nov 22, 2024
b896207
Merge pull request #36 from PADAS/initial-release
vgarcia13 Nov 22, 2024
575124a
Revert "Initial release"
vgarcia13 Nov 22, 2024
539b4cc
Merge pull request #37 from PADAS/revert-36-initial-release
chrisdoehring Nov 23, 2024
4db0c9b
Support setting action schedules by command or decorator
marianobrc Dec 18, 2024
92e57a5
Make timezone optional
marianobrc Dec 19, 2024
cb7aa86
Fix regular expressions for crontab
marianobrc Dec 19, 2024
a7794cb
Add test coverage for regitration with custom action schedule
marianobrc Dec 19, 2024
5e0bf20
Add instructions about custom schedule usage in the readme
marianobrc Dec 20, 2024
14b1314
Merge pull request #39 from PADAS/gundi-3819-action-schedules
marianobrc Dec 20, 2024
87677f6
Fix wrong mocks that may affect tests
marianobrc Dec 20, 2024
446db36
Merge pull request #40 from PADAS/fix-mock-responses
marianobrc Dec 20, 2024
5bc7734
Sync with the template repo
marianobrc Dec 20, 2024
f8afb45
Improve error handling in test auth
marianobrc Dec 20, 2024
2ce9f21
[WIP] Improve config form for authentication using jsonschema conditi…
marianobrc Dec 20, 2024
ce1dbb2
Update the er-client
marianobrc Dec 23, 2024
6d136ff
Improve auth config to show either token or usr n psw fields
marianobrc Dec 23, 2024
4ca8371
Improve error messages on invalid auth and add ToDo for teh ER client
marianobrc Dec 24, 2024
4976d17
Test coverage for test credentials
marianobrc Dec 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Template repo for integration in Gundi v2.
- Webhook execution complete
- Error occurred during webhook execution
- Optionally, use `log_action_activity()` or `log_webhook_activity()` to log custom messages which you can later see in the portal
- Optionally, use `@crontab_schedule()` or `register.py --schedule` to make an action to run on a custom schedule


## Action Examples:
Expand All @@ -35,10 +36,12 @@ class PullObservationsConfiguration(PullActionConfiguration):
# actions/handlers.py
from app.services.activity_logger import activity_logger, log_activity
from app.services.gundi import send_observations_to_gundi
from app.services.utils import crontab_schedule
from gundi_core.events import LogLevel
from .configurations import PullObservationsConfiguration


@crontab_schedule("0 */4 * * *") # Run every 4 hours
@activity_logger()
async def action_pull_observations(integration, action_config: PullObservationsConfiguration):

Expand Down
76 changes: 74 additions & 2 deletions app/actions/configurations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,28 @@

from pydantic import Field, SecretStr

from app.services.utils import GlobalUISchemaOptions
from .core import AuthActionConfiguration, PullActionConfiguration, ExecutableActionMixin


class ERAuthenticationType(str, Enum):
TOKEN = "token"
USERNAME_PASSWORD = "username_password"


class AuthenticateConfig(AuthActionConfiguration, ExecutableActionMixin):
authentication_type: ERAuthenticationType = Field(
ERAuthenticationType.TOKEN,
description="Type of authentication to use."
)
username: Optional[str] = Field(
"",
example="[email protected]",
example="myuser",
description="Username used to authenticate against Earth Ranger API",
)
password: Optional[SecretStr] = Field(
"",
example="passwd1234abc",
example="mypasswd1234abc",
description="Password used to authenticate against Earth Ranger API",
format="password"
)
Expand All @@ -25,14 +35,76 @@ class AuthenticateConfig(AuthActionConfiguration, ExecutableActionMixin):
description="Token used to authenticate against Earth Ranger API",
)

ui_global_options: GlobalUISchemaOptions = GlobalUISchemaOptions(
order=["authentication_type", "token", "username", "password"],
)

class Config:
@staticmethod
def schema_extra(schema: dict):
# Remove token, username, and password from the root properties
schema["properties"].pop("token", None)
schema["properties"].pop("username", None)
schema["properties"].pop("password", None)

# Show token OR username & password based on authentication_type
schema.update({
"if": {
"properties": {
"authentication_type": {"const": "token"}
}
},
"then": {
"required": ["token"],
"properties": {
"token": {
"title": "Token",
"description": "Token used to authenticate against Earth Ranger API",
"default": "",
"example": "1b4c1e9c-5ee0-44db-c7f1-177ede2f854a",
"type": "string"
}
}
},
"else": {
"required": ["username", "password"],
"properties": {
"username": {
"title": "Username",
"description": "Username used to authenticate against Earth Ranger API",
"default": "",
"example": "myuser",
"type": "string"
},
"password": {
"title": "Password",
"description": "Password used to authenticate against Earth Ranger API",
"default": "",
"example": "mypasswd1234abc",
"format": "password",
"type": "string",
"writeOnly": True
}
}
}
})


class PullObservationsConfig(PullActionConfiguration):
start_datetime: str
end_datetime: Optional[str] = None
force_run_since_start: bool = False

ui_global_options: GlobalUISchemaOptions = GlobalUISchemaOptions(
order=["start_datetime", "end_datetime", "force_run_since_start"],
)


class PullEventsConfig(PullActionConfiguration):
start_datetime: str
end_datetime: Optional[str] = None
force_run_since_start: bool = False

ui_global_options: GlobalUISchemaOptions = GlobalUISchemaOptions(
order=["start_datetime", "end_datetime", "force_run_since_start"],
)
5 changes: 2 additions & 3 deletions app/actions/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@ class ActionConfiguration(UISchemaModelMixin, BaseModel):
pass


# ToDo: Move this into the template
class ExecutableActionMixin:
class PullActionConfiguration(ActionConfiguration):
pass


class PullActionConfiguration(ActionConfiguration):
class ExecutableActionMixin:
pass


Expand Down
5 changes: 3 additions & 2 deletions app/actions/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ async def action_auth(integration: Integration, action_config: AuthenticateConfi
valid_credentials = await er_client.login()
else:
return {"valid_credentials": False, "error": "Please provide either a token or username/password."}
except ERClientException:
valid_credentials = False
except ERClientException as e:
# ToDo. Differentiate ER errors from invalid credentials in the ER client
return {"valid_credentials": False, "error": str(e)}
return {"valid_credentials": valid_credentials}


Expand Down
101 changes: 101 additions & 0 deletions app/actions/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import pytest
from erclient import ERClientException
from gundi_core.schemas.v2 import Integration


Expand Down Expand Up @@ -105,6 +106,106 @@ def mock_erclient_class(
return mocked_erclient_class



@pytest.fixture
def er_401_exception():
return ERClientException(
'Failed to GET to ER web service. provider_key: None, service: https://gundi-dev.staging.pamdas.org/api/v1.0, path: user/me,\n\t 401 from ER. Message: Authentication credentials were not provided. {"status":{"code":401,"message":"Unauthorized","detail":"Authentication credentials were not provided."}}'
)


@pytest.fixture
def er_500_exception():
return ERClientException(
'Failed to GET to ER web service. provider_key: None, service: https://gundi-dev.staging.pamdas.org/api/v1.0, path: user/me,\n\t 500 from ER. Message: duplicate key value violates unique constraint "observations_observation_tenant_source_at_unique"'
)


@pytest.fixture
def er_generic_exception():
return ERClientException(
'Failed to GET to ER web service. provider_key: None, service: https://gundi-dev.staging.pamdas.org/api/v1.0, path: user/me,\n\t Error from ER. Message: Something went wrong'
)


@pytest.fixture
def mock_erclient_class_with_error(
request,
mocker,
er_401_exception,
er_500_exception,
er_generic_exception,
er_client_close_response
):

if request.param == "er_401_exception":
er_error = er_401_exception
elif request.param == "er_500_exception":
er_error = er_500_exception
else:
er_error = er_generic_exception
mocked_erclient_class = mocker.MagicMock()
erclient_mock = mocker.MagicMock()
erclient_mock.get_me.side_effect = er_error
erclient_mock.auth_headers.side_effect = er_error
erclient_mock.get_events.side_effect = er_error
erclient_mock.get_observations.side_effect = er_error
erclient_mock.close.return_value = async_return(
er_client_close_response
)
erclient_mock.__aenter__.return_value = erclient_mock
erclient_mock.__aexit__.return_value = er_client_close_response
mocked_erclient_class.return_value = erclient_mock
return mocked_erclient_class



@pytest.fixture
def mock_erclient_class_with_auth_401(
mocker,
auth_headers_response,
er_401_exception,

):
mocked_erclient_class = mocker.MagicMock()
erclient_mock = mocker.MagicMock()
erclient_mock.get_me.side_effect = er_401_exception
erclient_mock.auth_headers.side_effect = er_401_exception
erclient_mock.get_events.side_effect = er_401_exception
erclient_mock.get_observations.side_effect = er_401_exception
erclient_mock.close.return_value = async_return(
er_client_close_response
)
erclient_mock.__aenter__.return_value = erclient_mock
erclient_mock.__aexit__.return_value = er_client_close_response
mocked_erclient_class.return_value = erclient_mock
return mocked_erclient_class


@pytest.fixture
def mock_erclient_class_with_auth_500(
mocker,
auth_headers_response,
er_500_exception,
get_events_response,
get_observations_response,
er_client_close_response
):
mocked_erclient_class = mocker.MagicMock()
erclient_mock = mocker.MagicMock()
erclient_mock.get_me.side_effect = er_500_exception
erclient_mock.auth_headers.side_effect = er_500_exception
erclient_mock.get_events.side_effect = er_500_exception
erclient_mock.get_observations.side_effect = er_500_exception
erclient_mock.close.return_value = async_return(
er_client_close_response
)
erclient_mock.__aenter__.return_value = erclient_mock
erclient_mock.__aexit__.return_value = er_client_close_response
mocked_erclient_class.return_value = erclient_mock
return mocked_erclient_class


@pytest.fixture
def mock_gundi_sensors_client_class(mocker, events_created_response, observations_created_response):
mock_gundi_sensors_client_class = mocker.MagicMock()
Expand Down
33 changes: 31 additions & 2 deletions app/actions/tests/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


@pytest.mark.asyncio
async def test_execute_auth_action(
async def test_execute_auth_action_with_valid_credentials(
mocker, mock_gundi_client_v2, mock_erclient_class, er_integration_v2,
mock_publish_event
):
Expand All @@ -19,7 +19,36 @@ async def test_execute_auth_action(

assert mock_gundi_client_v2.get_integration_details.called
assert mock_erclient_class.return_value.get_me.called
assert response == {"valid_credentials": True}
assert response.get("valid_credentials") == True


@pytest.mark.parametrize(
"mock_erclient_class_with_error",
[
"er_401_exception",
"er_500_exception",
"er_generic_exception",
],
indirect=["mock_erclient_class_with_error"])
@pytest.mark.asyncio
async def test_execute_auth_action_with_invalid_credentials(
mocker, mock_gundi_client_v2, er_integration_v2,
mock_publish_event, mock_erclient_class_with_error
):
mocker.patch("app.services.action_runner._portal", mock_gundi_client_v2)
mocker.patch("app.services.activity_logger.publish_event", mock_publish_event)
mocker.patch("app.services.action_runner.publish_event", mock_publish_event)
mocker.patch("app.actions.handlers.AsyncERClient", mock_erclient_class_with_error)

response = await execute_action(
integration_id=str(er_integration_v2.id),
action_id="auth"
)

assert mock_gundi_client_v2.get_integration_details.called
assert mock_erclient_class_with_error.return_value.get_me.called
assert response.get("valid_credentials") == False
assert "error" in response


@pytest.mark.asyncio
Expand Down
Loading
Loading