Skip to content

Commit

Permalink
Add quota api endpoint and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
nf679 committed Oct 9, 2024
1 parent a3fda9b commit 8bb1efd
Show file tree
Hide file tree
Showing 11 changed files with 672 additions and 662 deletions.
76 changes: 74 additions & 2 deletions nlds/authenticators/authenticate_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
"""Authentication functions for use by the routers."""
from fastapi import Depends, status
from fastapi.security import OAuth2PasswordBearer
from .jasmin_authenticator import JasminAuthenticator as Authenticator
from ..errors import ResponseError
from nlds.authenticators.jasmin_authenticator import JasminAuthenticator as Authenticator
from nlds_processors.catalog.catalog_models import File, Holding
from nlds.errors import ResponseError
from fastapi.exceptions import HTTPException

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="", auto_error=False)
Expand Down Expand Up @@ -95,3 +96,74 @@ async def authenticate_group(group: str, token: str = Depends(oauth2_scheme)):
detail = response_error.json()
)
return group


async def authenticate_user_group_role(user: str, group: str, token: str = Depends(oauth2_scheme)):
"""Check the user's role in the group by calling the authenticator's authenticate_user_group_role
method."""
if token is None:
response_error = ResponseError(
loc = ["authenticate_methods", "authenticate_group"],
msg = "Oauth token not supplied.",
type = "Forbidden."
)
raise HTTPException(
status_code = status.HTTP_403_FORBIDDEN,
detail = response_error.json()
)
elif not authenticator.authenticate_user_group_role(token, user, group):
response_error = ResponseError(
loc = ["authenticate_methods", "authenticate_user_group_role"],
msg = f"User is not a manager or deputy of the group {group}.",
type = "Resource not found."
)
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = response_error.json()
)
return True


async def user_has_get_holding_permission(user: str, group: str, holding: Holding):
"""Check whether a user has permission to view this holding."""
if not authenticator.user_has_get_holding_permission(user, group, holding):
response_error = ResponseError(
loc = ["authenticate_methods", "user_has_get_holding_permission"],
msg = f"User does not have get holding permission for {holding}.",
type = "Resource not found."
)
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = response_error.json()
)
return True


async def user_has_get_file_permission(session, user: str, group: str, file: File):
"""Check whether a user has permission to access a file."""
if not authenticator.user_has_get_file_permission(session, user, group, file):
response_error = ResponseError(
loc = ["authenticate_methods", "user_has_get_file_permission"],
msg = f"User does not have get file permission.",
type = "Resource not found."
)
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = response_error.json()
)
return True


async def user_has_delete_from_holding_permission(user: str, group: str, holding: Holding):
"""Check whether a user has permission to delete files from this holding."""
if not authenticator.user_has_delete_from_holding_permission(user, group, holding):
response_error = ResponseError(
loc = ["authenticate_methods", "user_has_delete_from_holding_permission"],
msg = f"User does not have delete from holding permission.",
type = "Resource not found."
)
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = response_error.json()
)
return True
21 changes: 21 additions & 0 deletions nlds/authenticators/base_authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""Base class used to authenticate / authorise the users, groups, collections,
etc.
"""
from nlds_processors.catalog.catalog_models import File, Holding
from abc import ABC


Expand All @@ -31,3 +32,23 @@ def authenticate_group(self, oauth_token: str, group: str):
def authenticate_user_group_role(self, oauth_token: str, user: str, group: str):
"""Validate whether the user has manager/deputy permissions in the group."""
return NotImplementedError

def user_has_get_holding_permission(self, user: str, group: str, holding: Holding) -> bool:
"""Check whether a user has permission to view this holding."""
return NotImplementedError

def user_has_get_file_permission(self, session, user: str, group: str, file: File) -> bool:
"""Check whether a user has permission to access a file."""
return NotImplementedError

def user_has_delete_from_holding_permission(self, user: str, group: str, holding: Holding) -> bool:
"""Check whether a user has permission to delete files from this holding."""
return NotImplementedError

def get_service_information(self, oauth_token: str, service_name: str):
"""Get the information about the given service."""
return NotImplementedError

def extract_tape_quota(self, oauth_token: str, service_name: str):
"""Process the service inforrmation to return the tape quota value."""
return NotImplementedError
129 changes: 126 additions & 3 deletions nlds/authenticators/jasmin_authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
__license__ = "BSD - see LICENSE file in top-level package directory"
__contact__ = "[email protected]"

from .base_authenticator import BaseAuthenticator
from ..server_config import load_config
from ..utils.construct_url import construct_url
from nlds.authenticators.base_authenticator import BaseAuthenticator
from nlds.server_config import load_config
from nlds.utils.construct_url import construct_url
from nlds_processors.catalog.catalog_models import File, Holding, Transaction
from retry import retry
import requests
import json
Expand Down Expand Up @@ -237,3 +238,125 @@ def authenticate_user_group_role(self, oauth_token: str, user: str, group: str):
)
else:
return False


@staticmethod
def user_has_get_holding_permission(user: str,
group: str,
holding: Holding) -> bool:
"""Check whether a user has permission to view this holding.
When we implement ROLES this will be more complicated."""
permitted = True
#Users can view / get all holdings in their group
#permitted &= holding.user == user
permitted &= holding.group == group
return permitted


def user_has_get_file_permission(session,
user: str,
group: str,
file: File) -> bool:
"""Check whether a user has permission to access a file.
Later, when we implement the ROLES this function will be a lot more
complicated!"""
assert(session != None)
holding = session.query(Holding).filter(
Transaction.id == file.transaction_id,
Holding.id == Transaction.holding_id
).all()
permitted = True
for h in holding:
# users have get file permission if in group
# permitted &= h.user == user
permitted &= h.group == group

return permitted

@staticmethod
def user_has_delete_from_holding_permission(self, user: str,
group: str,
holding: Holding) -> bool:
"""Check whether a user has permission to delete files from this holding.
When we implement ROLES this will be more complicated."""
# is_admin == whether the user is an administrator of the group
# i.e. a DEPUTY or MANAGER
# this gives them delete permissions for all files in the group
is_admin = self.authenticate_user_group_role(user, group)
permitted = True
# Currently, only users can delete files from their owned holdings
permitted &= (holding.user == user or is_admin)
permitted &= holding.group == group
return permitted


@retry(requests.ConnectTimeout, tries=5, delay=1, backoff=2)
def get_service_information(self, oauth_token: str, service_name: str):
"""Make a call to the JASMIN Projects Portal to get the service information."""

config = self.config[self.auth_name][self.name]
token_headers = {
"Content-Type": "application/x-ww-form-urlencoded",
"cache-control": "no-cache",
"Authorization": f"Bearer {oauth_token}",
}
# Contact the user_services_url to get the information about the services
url = construct_url([config["user_services_urk"]], {"name": {service_name}})
try:
response = requests.get(
url,
headers=token_headers,
timeout=JasminAuthenticator._timeout,
)
except requests.exceptions.ConnectionError:
raise RuntimeError(f"User services url {url} could not be reached.")
except KeyError:
raise RuntimeError(f"Could not find 'user_services_url' key in the {self.name} section of the .server_config file.")
if response.status_code == requests.codes.ok: # status code 200
try:
response_json = json.loads(response.text)
return response_json
except json.JSONDecodeError:
raise RuntimeError(f"Invalid JSON returned from the user services url: {url}")
else:
raise RuntimeError(f"Error getting data for {service_name}")


def extract_tape_quota(self, oauth_token: str, service_name: str):
"""Get the service information then process it to extract the quota for the service."""
try:
result = self.get_service_information(self, oauth_token, service_name)
except (RuntimeError, ValueError) as e:
raise type(e)(f"Error getting information for {service_name}: {e}")

# Process the result to get the requirements
for attr in result:
# Check that the category is Group Workspace
if attr["category"] == 1:
# Check that there are requirements, otherwise throw an error
if attr["requirements"]:
requirements = attr["requirements"]
else:
raise ValueError(f"Cannot find any requirements for {service_name}.")
else:
raise ValueError(f"Cannot find a Group Workspace with the name {service_name}. Check the category.")

# Go through the requirements to find the tape resource requirement
for requirement in requirements:
# Only return provisioned requirements
if requirement["status"] == 50:
# Find the tape resource and get its quota
if requirement["resource"]["short_name"] == "tape":
try:
tape_quota = requirement["amount"]
if tape_quota:
return tape_quota
else:
raise ValueError(f"Issue getting tape quota for {service_name}. Quota is zero.")
except KeyError:
raise KeyError(f"Issue getting tape quota for {service_name}. No 'value' field exists.")
else:
raise ValueError(f"No tape resources could be found for {service_name}")
else:
raise ValueError(f"No provisioned requirements found for {service_name}.Check the status of your requested resources.")

1 change: 1 addition & 0 deletions nlds/rabbit/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class RabbitMQPublisher():
MSG_TENANCY = "tenancy"
MSG_ACCESS_KEY = "access_key"
MSG_SECRET_KEY = "secret_key"
MSG_TOKEN = "token"
MSG_API_ACTION = "api_action"
MSG_JOB_LABEL = "job_label"
MSG_DATA = "data"
Expand Down
56 changes: 53 additions & 3 deletions nlds/routers/quota.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@
from typing import Optional, List, Dict

from ..rabbit.publisher import RabbitMQPublisher as RMQP
from ..routers import rpc_publisher
from ..errors import ResponseError
from ..authenticators.authenticate_methods import authenticate_token, \
authenticate_group, \
authenticate_user

from ..utils.process_tag import process_tag


router = APIRouter()

class QuotaResponse(BaseModel):
Expand All @@ -25,19 +29,20 @@ class QuotaResponse(BaseModel):
@router.get("/",
status_code = status.HTTP_202_ACCEPTED,
responses = {
status.HTTPS_202_ACCEPTED: {"model": QuotaResponse},
status.HTTP_202_ACCEPTED: {"model": QuotaResponse},
status.HTTP_400_BAD_REQUEST: {"model": ResponseError},
status.HTTP_401_UNAUTHORIZED: {"model": ResponseError},
status.HTTP_403_FORBIDDEN: {"model": ResponseError},
status.HTTP_404_NOT_FOUND: {"model": ResponseError},
status.HTTP_504_GATEWAY: {"model": ResponseError},
status.HTTP_504_GATEWAY_TIMEOUT: {"model": ResponseError},
}
)
async def get(token: str = Depends(authenticate_token),
user: str = Depends(authenticate_user),
group: str = Depends(authenticate_group),
label: Optional[str] = None,
holding_id: Optional[int] = None,
transaction_id: Optional[str] = None,
tag: Optional[str] = None
):
# create the message dictionary
Expand All @@ -48,6 +53,7 @@ async def get(token: str = Depends(authenticate_token),
RMQP.MSG_DETAILS: {
RMQP.MSG_USER: user,
RMQP.MSG_GROUP: group,
RMQP.MSG_TOKEN: token,
},
RMQP.MSG_DATA: {},
RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD
Expand All @@ -58,5 +64,49 @@ async def get(token: str = Depends(authenticate_token),
meta_dict[RMQP.MSG_LABEL] = label
if (holding_id):
meta_dict[RMQP.MSG_HOLDING_ID] = holding_id
if (transaction_id):
meta_dict[RMQP.MSG_TRANSACT_ID] = transaction_id

if (tag):
# convert the string into a dictionary
tag_dict = {}
# convert the string into a dictionary
try:
tag_dict = process_tag(tag)
except ValueError:
response_error = ResponseError(
loc = ["quota", "get"],
msg = "tag cannot be processed.",
type = "Incomplete request."
)
raise HTTPException(
status_code = status.HTTP_400_BAD_REQUEST,
detail = response_error.json()
)
else:
meta_dict[RMQP.MSG_TAG] = tag_dict

if (len(meta_dict) > 0):
msg_dict[RMQP.MSG_META] = meta_dict

# Call RPC function
routing_key = "catalog_q"
response = await rpc_publisher.call(
msg_dict=msg_dict, routing_key=routing_key
)
# Check if response is valid or whether the request timed out
if response is not None:
# convert byte response to str
response = response.decode()

return JSONResponse(status_code = status.HTTP_202_ACCEPTED,
content = response)
else:
response_error = ResponseError(
loc = ["status", "get"],
msg = "Catalog service could not be reached in time.",
type = "Incomplete request."
)
raise HTTPException(
status_code = status.HTTP_504_GATEWAY_TIMEOUT,
detail = response_error.json()
)
Loading

0 comments on commit 8bb1efd

Please sign in to comment.