Skip to content

Commit

Permalink
Merge pull request #5 from PADAS/gundi-3795-improve-auth
Browse files Browse the repository at this point in the history
GUNDI-3795: Improve authentication config
  • Loading branch information
marianobrc authored Dec 24, 2024
2 parents 7839a91 + 4976d17 commit 34004fd
Show file tree
Hide file tree
Showing 14 changed files with 1,499 additions and 639 deletions.
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="user@pamdas.org",
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

0 comments on commit 34004fd

Please sign in to comment.