Skip to content

Commit

Permalink
feat: #1139 add forest client validator (#1176)
Browse files Browse the repository at this point in the history
  • Loading branch information
MCatherine1994 authored Feb 8, 2024
1 parent f14c878 commit 2f02a51
Show file tree
Hide file tree
Showing 15 changed files with 495 additions and 39 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ jobs:
python-version: 3.8

- name: Tests and coverage
env:
FC_API_TOKEN: ${{ secrets.FOREST_CLIENT_API_API_KEY }}
run: |
cd server/admin_management
pip install -r requirements.txt -r requirements-dev.txt
Expand Down
3 changes: 3 additions & 0 deletions infrastructure/server/fam_admin_management_api.tf
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ resource "aws_lambda_function" "fam_admin_management_api_function" {
COGNITO_CLIENT_ID = "3hv7q2mct0okt12m5i3p5v4phu"

ALLOW_ORIGIN = "${var.front_end_redirect_path}"

FC_API_TOKEN = "${var.forest_client_api_api_key}"
FC_API_BASE_URL = "${var.forest_client_api_base_url}"
}

}
Expand Down
5 changes: 5 additions & 0 deletions server/admin_management/api/app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ class AdminRoleAuthGroup(str, Enum):
APP_ADMIN = "APP_ADMIN"
DELEGATED_ADMIN = "DELEGATED_ADMIN"


FOREST_CLIENT_STATUS = {
"KEY": "clientStatusCode",
"CODE_ACTIVE": "ACT"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import logging
from http import HTTPStatus
from typing import List

import requests
from api.config import config
from api.app.schemas import ForestClientIntegrationFindResponse

LOGGER = logging.getLogger(__name__)


class ForestClientIntegrationService():
"""
The class is used for making requests to get information from Forest Client API.
Api is located at BC API Service Portal: https://api.gov.bc.ca/devportal/api-directory/3179.
API-key needs to be requested from the portal.
Spec of API:
test: https://nr-forest-client-api-test.api.gov.bc.ca/
prod: https://nr-forest-client-api-prod.api.gov.bc.ca/
"""
# https://requests.readthedocs.io/en/latest/user/quickstart/#timeouts
# https://docs.python-requests.org/en/latest/user/advanced/#timeouts
TIMEOUT = (5, 10) # Timeout (connect, read) in seconds.

def __init__(self):
self.api_base_url = config.get_forest_client_api_baseurl()
self.api_clients_url = f"{self.api_base_url}/api/clients"
self.API_TOKEN = config.get_forest_client_api_token()
self.headers = {"Accept": "application/json", "X-API-KEY": self.API_TOKEN}

# See Python: https://requests.readthedocs.io/en/latest/user/advanced/
self.session = requests.Session()
self.session.headers.update(self.headers)

def find_by_client_number(self, p_client_number: str) -> List[ForestClientIntegrationFindResponse]:
"""
Find Forest Client(s) information based on p_client_number search query field.
:param p_client_number: Forest Client Number string (8 digits).
Note! Current Forest Client API can only do exact match.
'/api/clients/findByClientNumber/{clientNumber}'
:return: Search result as List for a Forest Client information object.
Current Forest Client API returns exact one result or http status
other than 200 with message content. The intent for FAM search is for
wild card search and Forest Client API could be capable of doing that
in next version.
"""
url = f"{self.api_clients_url}/findByClientNumber/{p_client_number}"
LOGGER.debug(f"ForestClientIntegrationService find_by_client_number() - url: {url}")

try:
r = self.session.get(url, timeout=self.TIMEOUT)
r.raise_for_status()
# !! Don't map and return schema.FamForestClient or object from "scheam.py" as that
# will create circular dependency issue. let crud to map the result.
api_result = r.json()
LOGGER.debug(f"API result: {api_result}")
return [api_result]

# Below except catches only HTTPError not general errors like network connection/timeout.
except requests.exceptions.HTTPError as he:
status_code = r.status_code
LOGGER.debug(f"API status code: {status_code}")
LOGGER.debug(f"API result: {r.content or r.reason}")

# For some reason Forest Client API uses (a bit confusing):
# - '404' as general "client 'Not Found'", not as conventional http Not Found.
#
# Forest Client API returns '400' as "Invalid Client Number"; e.g. /findByClientNumber/abcde0001
# Howerver FAM 'search' (as string type param) is intended for either 'number' or 'name' search),
# so if 400, will return empty.
if ((status_code == HTTPStatus.NOT_FOUND) or (status_code == HTTPStatus.BAD_REQUEST)):
return [] # return empty for FAM forest client search

# Else raise error, including 500
# There is a general error handler, see: requests_http_error_handler
raise he
40 changes: 26 additions & 14 deletions server/admin_management/api/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,13 @@ class FamRoleCreateDto(FamRoleBase):
parent_role_id: Union[int, None] = Field(
default=None, title="Reference role_id to higher role"
)
forest_client_number: Optional[
Annotated[str, StringConstraints(max_length=8)]
] = Field(default=None, title="Forest Client this role is associated with")
forest_client_number: Optional[Annotated[str, StringConstraints(max_length=8)]] = (
Field(default=None, title="Forest Client this role is associated with")
)
create_user: Annotated[str, StringConstraints(max_length=60)]
client_number: Optional[
FamForestClientCreateDto
] = None # this is matched with the model
client_number: Optional[FamForestClientCreateDto] = (
None # this is matched with the model
)

model_config = ConfigDict(from_attributes=True)

Expand Down Expand Up @@ -226,12 +226,15 @@ class FamAccessControlPrivilegeCreateResponse(BaseModel):
# ------------------------------------- FAM Admin User Access ---------------------------------------- #
class FamApplicationDto(BaseModel):
id: int = Field(validation_alias="application_id")
name: Annotated[str, StringConstraints(max_length=100)] = \
Field(validation_alias="application_name")
description: Annotated[Optional[str], StringConstraints(max_length=200)] = \
Field(default=None, validation_alias="application_description")
env: Optional[famConstants.AppEnv] = \
Field(validation_alias="app_environment", default=None)
name: Annotated[str, StringConstraints(max_length=100)] = Field(
validation_alias="application_name"
)
description: Annotated[Optional[str], StringConstraints(max_length=200)] = Field(
default=None, validation_alias="application_description"
)
env: Optional[famConstants.AppEnv] = Field(
validation_alias="app_environment", default=None
)

model_config = ConfigDict(from_attributes=True)

Expand All @@ -240,8 +243,9 @@ class FamRoleDto(BaseModel):
# Note, this "id" for role can either be concrete role's or abstract role's id.
# In abstract role with this id, forest_clients should be present.
id: int = Field(validation_alias="role_id")
name: Annotated[str, StringConstraints(max_length=100)] = \
Field(validation_alias="role_name")
name: Annotated[str, StringConstraints(max_length=100)] = Field(
validation_alias="role_name"
)
type_code: famConstants.RoleType = Field(validation_alias="role_type_code")
forest_clients: Optional[List[str]] = Field(default=None)

Expand All @@ -266,3 +270,11 @@ class AdminUserAccessResponse(BaseModel):
access: List[FamAuthGrantDto]

model_config = ConfigDict(from_attributes=True)


# ------------------------------------- Forest Client API Integraion ---------------------------------------- #
class ForestClientIntegrationFindResponse(BaseModel):
clientNumber: str
clientName: str
clientStatusCode: str
clientTypeCode: str
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@

from api.app import constants as famConstants
from api.app import schemas
from api.app.repositories.access_control_privilege_repository import \
AccessControlPrivilegeRepository

from api.app.integration.forest_client_integration import ForestClientIntegrationService
from api.app.repositories.access_control_privilege_repository import (
AccessControlPrivilegeRepository,
)
from api.app.services.role_service import RoleService
from api.app.services.user_service import UserService
from api.app.services.validator.forest_client_validator import (
forest_client_number_exists,
forest_client_active,
get_forest_client_status,
)
from api.app.utils import utils
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -76,9 +84,28 @@ def create_access_control_privilege_many(
error_msg = "Invalid access control privilege request, missing forest client number."
utils.raise_http_exception(HTTPStatus.BAD_REQUEST, error_msg)

forest_client_integration_service = ForestClientIntegrationService()
for forest_client_number in request.forest_client_numbers:
# validate the forest client number
forest_client_validator_return = (
forest_client_integration_service.find_by_client_number(forest_client_number)
)
if not forest_client_number_exists(forest_client_validator_return):
error_msg = (
"Invalid access control privilege request. "
+ f"Forest client number {forest_client_number} does not exist."
)
utils.raise_http_exception(HTTPStatus.BAD_REQUEST, error_msg)

if not forest_client_active(forest_client_validator_return):
error_msg = (
"Invalid access control privilege request. "
+ f"Forest client number {forest_client_number} is not in active status: "
+ f"{get_forest_client_status(forest_client_validator_return)}."
)
utils.raise_http_exception(HTTPStatus.BAD_REQUEST, error_msg)

# Check if child role exists or add a new child role
# NOSONAR TODO: validate the forest client number
child_role = self.role_service.find_or_create_forest_client_child_role(
forest_client_number, fam_role, requester
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import logging
from typing import List, Union

from api.app.constants import FOREST_CLIENT_STATUS
from api.app.schemas import ForestClientIntegrationFindResponse


LOGGER = logging.getLogger(__name__)


def forest_client_number_exists(
forest_client_find_result: List[ForestClientIntegrationFindResponse],
) -> bool:
# Exact client number search - should only contain 1 result.
return len(forest_client_find_result) == 1


def forest_client_active(
forest_client_find_result: List[ForestClientIntegrationFindResponse],
) -> bool:
return (
(
forest_client_find_result[0][FOREST_CLIENT_STATUS["KEY"]]
== FOREST_CLIENT_STATUS["CODE_ACTIVE"]
)
if forest_client_number_exists(forest_client_find_result)
else False
)


def get_forest_client_status(
forest_client_find_result: List[ForestClientIntegrationFindResponse],
) -> Union[str, None]:
return (
forest_client_find_result[0][FOREST_CLIENT_STATUS["KEY"]]
if forest_client_number_exists(forest_client_find_result)
else None
)
37 changes: 27 additions & 10 deletions server/admin_management/api/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,18 @@ def get_aws_db_string():
username = secret_json.get("username")
password = secret_json.get("password")

host = get_env_var('PG_HOST')
port = get_env_var('PG_PORT')
dbname = get_env_var('PG_DATABASE')
host = get_env_var("PG_HOST")
port = get_env_var("PG_PORT")
dbname = get_env_var("PG_DATABASE")
return f"postgresql+psycopg2://{username}:{password}@{host}:{port}/{dbname}"


def get_local_dev_db_string():
username = get_env_var('POSTGRES_USER')
password = get_env_var('POSTGRES_PASSWORD')
host = get_env_var('POSTGRES_HOST')
port = get_env_var('POSTGRES_PORT')
dbname = get_env_var('POSTGRES_DB')
username = get_env_var("POSTGRES_USER")
password = get_env_var("POSTGRES_PASSWORD")
host = get_env_var("POSTGRES_HOST")
port = get_env_var("POSTGRES_PORT")
dbname = get_env_var("POSTGRES_DB")
return f"postgresql+psycopg2://{username}:{password}@{host}:{port}/{dbname}"


Expand All @@ -90,6 +90,7 @@ def get_aws_db_secret():

return client.get_secret_value(SecretId=secret_name)


def get_aws_region():
env_var = "COGNITO_REGION"
return get_env_var(env_var)
Expand All @@ -99,7 +100,6 @@ def get_aws_region():


def get_oidc_client_id():

# Outside of AWS, you can set COGNITO_CLIENT_ID
# Inside AWS, you have to get this value from an AWS Secret

Expand All @@ -121,10 +121,27 @@ def get_oidc_client_id():

return _client_id


def get_user_pool_domain_name():
env_var = "COGNITO_USER_POOL_DOMAIN"
return get_env_var(env_var)


def get_user_pool_id():
env_var = "COGNITO_USER_POOL_ID"
return get_env_var(env_var)
return get_env_var(env_var)


def get_forest_client_api_token():
api_token = get_env_var("FC_API_TOKEN")
return api_token


def get_forest_client_api_baseurl():
forest_client_api_baseurl = (
get_env_var("FC_API_BASE_URL")
if is_on_aws()
else "https://nr-forest-client-api-test.api.gov.bc.ca"
) # Test env.
LOGGER.info(f"Using forest_client_api_baseurl -- {forest_client_api_baseurl}")
return forest_client_api_baseurl
1 change: 1 addition & 0 deletions server/admin_management/local-dev.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ COGNITO_REGION=ca-central-1
COGNITO_USER_POOL_ID=ca-central-1_p8X8GdjKW
COGNITO_CLIENT_ID=6jfveou69mgford233or30hmta
COGNITO_USER_POOL_DOMAIN=dev-fam-user-pool-domain
FC_API_TOKEN=thisisasecret
Loading

0 comments on commit 2f02a51

Please sign in to comment.