diff --git a/src/api.py b/src/api.py index 0e46a53..1461fe1 100644 --- a/src/api.py +++ b/src/api.py @@ -2,7 +2,7 @@ This module hooks the routers for the main endpoints into a single router for importing to the app. """ -from fastapi import APIRouter, status +from fastapi import APIRouter from src.auth.router import router as auth_router from src.contact.router import router as contact_router @@ -11,7 +11,6 @@ from src.user.router import router as user_router from src.admin.router import router as admin_router from src.iam.router import router as iam_router from src.service.router import router as service_router -from src.schemas import CustomBaseModel api_router = APIRouter(prefix="/api/v1") @@ -25,16 +24,7 @@ api_router.include_router(service_router) api_router.include_router(iam_router) -class HealthCheckResponse(CustomBaseModel): - status: str - - -@api_router.get( - path="/healthcheck", - status_code=status.HTTP_200_OK, - response_model=HealthCheckResponse, - include_in_schema=False, -) +@api_router.get("/healthcheck", include_in_schema=False) def healthcheck(): - """Simple health check endpoint.""" + """Simple healthcheck endpoint.""" return {"status": "ok"} diff --git a/src/auth/dependencies.py b/src/auth/dependencies.py index ddba545..e29b641 100644 --- a/src/auth/dependencies.py +++ b/src/auth/dependencies.py @@ -11,7 +11,6 @@ Exports: from typing import Annotated from fastapi import Depends -from src.exceptions import ForbiddenException from src.user.dependencies import user_model_claims_dependency from src.user.models import User from src.organisation.dependencies import ( @@ -20,6 +19,8 @@ from src.organisation.dependencies import ( ) from src.organisation.models import Organisation as Org +from src.auth.exceptions import UnauthorizedException + async def org_query_user_claims( org_model: org_model_query_dependency, user_model: user_model_claims_dependency @@ -27,7 +28,7 @@ async def org_query_user_claims( if user_model in org_model.user_rel: return True - raise ForbiddenException() + raise UnauthorizedException() org_query_user_claims_dependency = Annotated[bool, Depends(org_query_user_claims)] @@ -44,10 +45,10 @@ async def org_query_root_claims( try: if await user_model_super_admin(user_model, su_emails): return org_model - except ForbiddenException: + except UnauthorizedException: pass - raise ForbiddenException(message="Must be the org's root user") + raise UnauthorizedException(message="Must be the org's root user") org_model_root_claim_query_dependency = Annotated[ @@ -66,10 +67,10 @@ async def org_body_root_claims( try: if await user_model_super_admin(user_model, su_emails): return org_model - except ForbiddenException: + except UnauthorizedException: pass - raise ForbiddenException(message="Must be the org's root user") + raise UnauthorizedException(message="Must be the org's root user") org_model_root_claim_body_dependency = Annotated[ @@ -98,7 +99,7 @@ async def user_model_super_admin( if user_model.email in super_admin_emails: return user_model - raise ForbiddenException(message="Must be super admin") + raise UnauthorizedException(message="Must be super admin") super_admin_dependency = Annotated[type[User], Depends(user_model_super_admin)] diff --git a/src/auth/exceptions.py b/src/auth/exceptions.py index 30ec2ed..f4a2cba 100644 --- a/src/auth/exceptions.py +++ b/src/auth/exceptions.py @@ -4,3 +4,16 @@ Module specific exceptions for the auth module Exceptions: - UnauthorizedException: Takes an optional message string """ + +from typing import Optional + +from fastapi import HTTPException, status + + +class UnauthorizedException(HTTPException): + def __init__(self, message: Optional[str] = None) -> None: + detail = "Not authorized" if not message else message + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + ) diff --git a/src/auth/service.py b/src/auth/service.py index 25c2fa7..b9f436b 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -17,7 +17,7 @@ from urllib.request import urlopen from fastapi import Depends from fastapi.security import OpenIdConnect -from src.exceptions import UnauthorizedException +from src.auth.exceptions import UnauthorizedException from src.auth.config import auth_settings from src.user.service import add_user_to_db from src.database import db_dependency diff --git a/src/exceptions.py b/src/exceptions.py index 1f5fdcb..5a6ed3a 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -36,12 +36,3 @@ class ForbiddenException(HTTPException): status_code=status.HTTP_403_FORBIDDEN, detail=detail, ) - - -class UnauthorizedException(HTTPException): - def __init__(self, message: Optional[str] = None) -> None: - detail = "Not authorized" if not message else message - super().__init__( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=detail, - ) diff --git a/src/iam/router.py b/src/iam/router.py index 5096bde..810c2aa 100644 --- a/src/iam/router.py +++ b/src/iam/router.py @@ -24,13 +24,10 @@ from sqlalchemy.exc import IntegrityError from src.iam.exceptions import GroupNotFoundException from src.organisation.exceptions import OrgNotFoundException from src.schemas import GroupSummary, OrgSummary, ResourceName -from src.service.dependencies import service_model_body_dependency -from src.exceptions import ( - ConflictException, - ForbiddenException, - UnprocessableContentException, -) +from src.service.exceptions import ServiceNotFoundException +from src.exceptions import ConflictException, ForbiddenException from src.database import db_dependency +from src.auth.exceptions import UnauthorizedException from src.auth.service import claims_dependency from src.auth.dependencies import ( org_model_root_claim_query_dependency, @@ -60,6 +57,7 @@ from src.iam.dependencies import ( perm_model_query_dependency, ) from src.iam.schemas import ( + GroupSchema, IAMCAoRRequest, IAMGetGroupPermissionsResponse, IAMGetGroupUsersResponse, @@ -79,8 +77,6 @@ from src.iam.schemas import ( IAMPutGroupInvitationRequest, IAMPutGroupInvitationAcceptRequest, IAMCAoRResponse, - IAMPutGroupInvitationAcceptResponse, - IAMPutGroupInvitationResponse, ) from src.utils import verify_email_token @@ -162,56 +158,9 @@ async def can_act_on_resource( status_code=status.HTTP_200_OK, response_model=IAMGetGroupPermissionsResponse, responses={ - status.HTTP_422_UNPROCESSABLE_CONTENT: { - "description": "Unprocessable content.", - "content": { - "application/json": { - "examples": { - "org_id": {"summary": "Invalid or missing org ID."}, - "oidc_claims": {"summary": "Invalid or missing OIDC claims."}, - } - } - }, - }, status.HTTP_401_UNAUTHORIZED: { - "description": "Unauthorized", - "content": { - "application/json": { - "examples": { - "awaiting_approval": { - "summary": "Organisation has not yet been approved." - }, - "expired_token": {"summary": "User token has expired."}, - "oidc": {"summary": "Failed to verify OIDC claims."}, - } - } - }, - }, - status.HTTP_403_FORBIDDEN: { - "description": "Forbidden", - "content": { - "application/json": { - "examples": { - "not_root": {"summary": "Not authorised. Must be root user."}, - } - } - }, - }, - status.HTTP_404_NOT_FOUND: { - "description": "Not found", - "content": { - "application/json": { - "examples": { - "db_id": { - "summary": "User not found in db when checking claims." - }, - "user_model": {"summary": "User model not found in db."}, - "org_model": {"summary": "Org model not found in db."}, - "group_model": {"summary": "Group model not found in db."}, - } - } - }, - }, + "description": "Group does not belong to this organisation" + } }, ) async def get_group_permissions( @@ -222,7 +171,7 @@ async def get_group_permissions( Gets a list of permissions granted to the group. Also returns a summary for the org and group. """ if group_model.org_id != org_model.id: - raise ForbiddenException("Group does not belong to this organization") + raise UnauthorizedException("Group does not belong to this organization") return { "organisation": org_model, "group": group_model, @@ -249,7 +198,7 @@ async def get_group_users( Gets a list of users assigned to the group. Also returns a summary for the org and group. """ if group_model.org_id != org_model.id: - raise ForbiddenException("Group does not belong to this organization") + raise UnauthorizedException("Group does not belong to this organization") return { "organisation": org_model, "group": group_model, @@ -287,10 +236,9 @@ async def create_group( or "UNIQUE constraint failed" in str(e.orig) # SQLite unique violation ): raise ConflictException("Group with this name already exists") - group_response = GroupSummary(**group_model.__dict__) - org_response = OrgSummary(**org_model.__dict__) + response = GroupSchema(**group_model.__dict__) db.commit() - return {"group": group_response, "organisation": org_response} + return {"group": response} @router.put( @@ -318,7 +266,7 @@ async def add_group_permission( Grants a permission to a group. Returns a list of the permissions in the group as well as a summary for the org and group. """ if group_model.org_id != org_model.id: - raise ForbiddenException("Group does not belong to this organization") + raise UnauthorizedException("Group does not belong to this organization") if perm_model in group_model.permission_rel: raise ConflictException("Group already has this permission") @@ -363,7 +311,7 @@ async def add_group_user( The user's email address must match the email on their OIDC profile. """ if group_model.org_id != org_model.id: - raise ForbiddenException("Group does not belong to this organization") + raise UnauthorizedException("Group does not belong to this organization") if user_model in group_model.user_rel: raise ConflictException("User already in group") @@ -376,14 +324,14 @@ async def add_group_user( group_model.user_rel.append(user_model) db.flush() response = IAMPutGroupUserResponse( - group=GroupSummary(**group_model.__dict__), users=group_model.user_rel + group=GroupSchema(**group_model.__dict__), users=group_model.user_rel ) db.commit() return response @router.delete( - path="/group/permission", + path="/group/permissions", summary="Removes a permission from the group", status_code=status.HTTP_200_OK, response_model=IAMDeleteGroupPermissionResponse, @@ -393,7 +341,7 @@ async def add_group_user( }, }, ) -async def remove_group_permission( +async def remove_group_permissions( db: db_dependency, group_model: group_model_query_dependency, perm_model: perm_model_query_dependency, @@ -403,15 +351,12 @@ async def remove_group_permission( Removes a permission from the group. """ if group_model.org_id != org_model.id: - raise ForbiddenException("Group does not belong to this organization") - - if perm_model not in group_model.permission_rel: - raise UnprocessableContentException("Permission not granted to group") + raise UnauthorizedException("Group does not belong to this organization") group_model.permission_rel.remove(perm_model) db.flush() response = IAMDeleteGroupPermissionResponse( - group=GroupSummary(**group_model.__dict__), + group=GroupSchema(**group_model.__dict__), permissions=group_model.permission_rel, ) db.commit() @@ -425,9 +370,8 @@ async def remove_group_permission( response_model=IAMDeleteGroupUserResponse, responses={ status.HTTP_401_UNAUTHORIZED: { - "description": "User not authenticated | User does not have permission" + "description": "Group not in org | User not authenticated | User does not have permission" }, - status.HTTP_403_FORBIDDEN: {"description": "Group not in org"}, }, ) async def remove_group_user( @@ -440,12 +384,12 @@ async def remove_group_user( Removes a user from the group. """ if group_model.org_id != org_model.id: - raise ForbiddenException("Group does not belong to this organization") + raise UnauthorizedException("Group does not belong to this organization") user_model.group_rel.remove(group_model) db.flush() response = IAMDeleteGroupUserResponse( - group=GroupSummary(**group_model.__dict__), users=group_model.user_rel + group=GroupSchema(**group_model.__dict__), users=group_model.user_rel ) db.commit() @@ -489,11 +433,13 @@ async def create_new_permission( db: db_dependency, su: super_admin_dependency, request_model: IAMPostPermissionRequest, - service_model: service_model_body_dependency, # Used to verify service model exists ): """ Allows a super admin to create a new IAM permission for a service. """ + service_model = db.get(Service, request_model.service_id) + if service_model is None: + raise ServiceNotFoundException(service_id=request_model.service_id) perm_model = Perm(**request_model.__dict__) db.add(perm_model) try: @@ -550,17 +496,17 @@ async def post_permissions( """ permission_query = db.query(Perm) - if not (request_model.service_id is None or request_model.service_id == ""): + if request_model.service_id is not None: permission_query = permission_query.filter( Perm.service_id == request_model.service_id ) - if not (request_model.resource is None or request_model.resource == ""): + if request_model.resource is not None: permission_query = permission_query.filter( Perm.resource == request_model.resource ) - if not (request_model.action is None or request_model.action == ""): + if request_model.action is not None: permission_query = permission_query.filter(Perm.action == request_model.action) permission_models = permission_query.all() @@ -572,7 +518,6 @@ async def post_permissions( path="/group/user/invitation", summary="Send an email invitation for non-org member to join a group", status_code=status.HTTP_200_OK, - response_model=IAMPutGroupInvitationResponse, responses={}, ) async def invitation( @@ -602,20 +547,13 @@ async def invitation( group_name=group_name, ) - response = { - "organisation": org_model, - "group": group_model, - "invited_email": user_email, - } - - return response + return "Invitation sent" @router.put( path="/group/user//invitation/accept", summary="Accept email invitation to join an org's group", status_code=status.HTTP_200_OK, - response_model=IAMPutGroupInvitationAcceptResponse, responses={ status.HTTP_404_NOT_FOUND: {"description": "User|Org|Group not found"}, status.HTTP_403_FORBIDDEN: { @@ -651,13 +589,6 @@ async def accept_invitation( raise ConflictException("User already in group.") group_model.user_rel.append(user_model) - db.flush() - - response = { - "organisation": org_model, - "user": user_model, - "group": {"details": group_model, "permissions": group_model.permission_rel}, - } db.commit() - return response + return "Invitation accepted" diff --git a/src/iam/schemas.py b/src/iam/schemas.py index d8e526b..1cae77e 100644 --- a/src/iam/schemas.py +++ b/src/iam/schemas.py @@ -42,9 +42,9 @@ class PermissionSchema(CustomBaseModel): action: str -class GroupDetails(CustomBaseModel): - details: GroupSummary - permissions: list[PermissionSchema] +class GroupSchema(CustomBaseModel): + id: int + name: str class IAMCAoRRequest(CustomBaseModel): @@ -76,8 +76,7 @@ class IAMPostGroupRequest(OrgIDMixin): class IAMPostGroupResponse(CustomBaseModel): - organisation: OrgSummary - group: GroupSummary + group: GroupSchema class IAMPutGroupPermissionRequest(GroupIDMixin, PermIDMixin, OrgIDMixin): @@ -95,17 +94,17 @@ class IAMPutGroupUserRequest(GroupIDMixin, UserIDMixin, OrgIDMixin): class IAMPutGroupUserResponse(CustomBaseModel): - group: GroupSummary + group: GroupSchema users: list[UserSchema] class IAMDeleteGroupPermissionResponse(CustomBaseModel): - group: GroupSummary + group: GroupSchema permissions: list[PermissionSchema] class IAMDeleteGroupUserResponse(CustomBaseModel): - group: GroupSummary + group: GroupSchema users: list[UserSchema] @@ -136,17 +135,5 @@ class IAMPutGroupInvitationRequest(OrgIDMixin, GroupIDMixin): user_email: EmailStr -class IAMPutGroupInvitationResponse(CustomBaseModel): - organisation: OrgSummary - group: GroupSummary - invited_email: EmailStr - - class IAMPutGroupInvitationAcceptRequest(CustomBaseModel): jwt: str - - -class IAMPutGroupInvitationAcceptResponse(CustomBaseModel): - organisation: OrgSummary - user: UserSummary - group: GroupDetails diff --git a/src/iam/service.py b/src/iam/service.py index e7d6699..6fe4d5f 100644 --- a/src/iam/service.py +++ b/src/iam/service.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone from src.iam.schemas import IAMCAoRRequest from src.service.models import Service from src.database import db_dependency -from src.exceptions import UnauthorizedException +from src.auth.exceptions import UnauthorizedException from src.utils import send_email, generate_jwt diff --git a/src/organisation/constants.py b/src/organisation/constants.py index 8d956ca..da4d60e 100644 --- a/src/organisation/constants.py +++ b/src/organisation/constants.py @@ -31,15 +31,7 @@ class Status(StrEnum): @property def is_pre_approval(self): - return self in (self.PARTIAL, self.SUBMITTED, self.REMEDIATION) - - @property - def is_pre_submission(self): - return self in (self.PARTIAL, self.REMEDIATION) - - @property - def is_blocked(self): - return self in (self.REMOVED, self.REJECTED) + return self in (self.PARTIAL, self.SUBMITTED) class ContactType(StrEnum): diff --git a/src/organisation/dependencies.py b/src/organisation/dependencies.py index 20c50a8..5ad9e0d 100644 --- a/src/organisation/dependencies.py +++ b/src/organisation/dependencies.py @@ -12,7 +12,6 @@ from sqlalchemy.orm import Session from fastapi import Depends, Query, Request from src.database import db_dependency -from src.exceptions import ForbiddenException from src.organisation.schemas import OrgIDMixin from src.organisation.models import Organisation as Org @@ -25,10 +24,6 @@ def get_org_model(db: Session, request: Request, org_id: int): if org_model is None: raise OrgNotFoundException(org_id) - org_status = OrgStatus(org_model.status) - if org_status.is_blocked: - raise ForbiddenException("This organisation cannot perform this action.") - root = "/api/v1" pre_approval_endpoints = [ @@ -63,7 +58,7 @@ def get_org_model_body( ) -> type[Org]: org_id: Optional[int] = getattr(request_model, "organisation_id", None) if org_id is None: - raise OrgNotFoundException() + raise OrgNotFoundException return get_org_model(db, request, org_id) diff --git a/src/organisation/router.py b/src/organisation/router.py index 3be0a2b..10f3521 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -23,12 +23,9 @@ from fastapi import APIRouter, status from fastapi.params import Query from sqlalchemy.exc import IntegrityError +from src.auth.exceptions import UnauthorizedException from src.contact.schemas import ContactModel -from src.exceptions import ( - UnprocessableContentException, - ConflictException, - ForbiddenException, -) +from src.exceptions import UnprocessableContentException, ConflictException from src.contact.models import Contact from src.contact.schemas import ContactAddress from src.contact.exceptions import ContactNotFoundException @@ -89,7 +86,7 @@ router = APIRouter( status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": "Missing or invalid org_id query parameter" }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be org root user." }, }, @@ -207,7 +204,7 @@ async def create_org( status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": "Invalid data in request." }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be org root user." }, }, @@ -222,11 +219,6 @@ async def update_questionnaire( The partial bool allows for submission of partially completed questionnaire and/or final "are you sure" check before setting the org to be in "submitted" status, awaiting admin approval. """ - org_status = StatusEnum(org_model.status) - if not org_status.is_pre_submission: - raise ForbiddenException( - "Questionnaire may only be modified prior to submission." - ) update_data = request_model.intake_questionnaire.model_dump(exclude_none=True) questionnaire = org_model.intake_questionnaire questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"]) @@ -267,7 +259,7 @@ async def update_questionnaire( status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": "Invalid data in request." }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be super admin." }, }, @@ -298,7 +290,7 @@ async def update_status( status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": "Org ID missing or invalid." }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be org root user." }, }, @@ -322,7 +314,7 @@ async def get_users(org_model: org_model_root_claim_query_dependency): status.HTTP_200_OK: { "description": "Successfully added user to the organisation." }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be org root user." }, status.HTTP_422_UNPROCESSABLE_CONTENT: { @@ -363,7 +355,7 @@ async def add_user_to_org( status.HTTP_204_NO_CONTENT: { "description": "Successfully deleted organisation." }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be super admin." }, status.HTTP_422_UNPROCESSABLE_CONTENT: { @@ -391,57 +383,11 @@ async def delete_organisation_by_id( status.HTTP_204_NO_CONTENT: { "description": "Successfully deleted organisation." }, - status.HTTP_422_UNPROCESSABLE_CONTENT: { - "description": "Unprocessable content.", - "content": { - "application/json": { - "examples": { - "org_id": {"summary": "Invalid or missing org ID."}, - "oidc_claims": {"summary": "Invalid or missing OIDC claims."}, - } - } - }, - }, status.HTTP_401_UNAUTHORIZED: { - "description": "Unauthorized", - "content": { - "application/json": { - "examples": { - "awaiting_approval": { - "summary": "Organisation has not yet been approved." - }, - "expired_token": {"summary": "User token has expired."}, - "oidc": {"summary": "Failed to verify OIDC claims."}, - } - } - }, + "description": "Not authorised. Must be root user." }, - status.HTTP_403_FORBIDDEN: { - "description": "Forbidden", - "content": { - "application/json": { - "examples": { - "invalid_state": { - "summary": "Organisation is no longer in pre-approval state." - }, - "not_root": {"summary": "Not authorised. Must be root user."}, - } - } - }, - }, - status.HTTP_404_NOT_FOUND: { - "description": "Not found", - "content": { - "application/json": { - "examples": { - "db_id": { - "summary": "User not found in db when checking claims." - }, - "user_model": {"summary": "User model not found in db."}, - "org_model": {"summary": "Org model not found in db."}, - } - } - }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Org ID missing or invalid." }, }, ) @@ -454,7 +400,7 @@ async def delete_preapproved_organisation_by_id( """ org_status = StatusEnum(org_model.status) if not org_status.is_pre_approval: - raise ForbiddenException( + raise UnauthorizedException( message="Organisation is no longer in pre-approval state." ) @@ -510,7 +456,7 @@ async def update_root_user( status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": "Org ID missing or invalid." }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be org root user." }, }, @@ -533,7 +479,7 @@ async def get_org_groups(org_model: org_model_root_claim_query_dependency): status_code=status.HTTP_204_NO_CONTENT, responses={ status.HTTP_204_NO_CONTENT: {"description": "Successfully removed user."}, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be org root user." }, status.HTTP_422_UNPROCESSABLE_CONTENT: { @@ -566,7 +512,7 @@ async def remove_user_from_org( status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": "Invalid data in request." }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be org root user." }, }, @@ -611,7 +557,7 @@ async def get_contact( status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": "Invalid data in request." }, - status.HTTP_403_FORBIDDEN: { + status.HTTP_401_UNAUTHORIZED: { "description": "Not authorised. Must be org root user." }, }, diff --git a/src/service/router.py b/src/service/router.py index 434d9bc..bfd282e 100644 --- a/src/service/router.py +++ b/src/service/router.py @@ -40,34 +40,13 @@ router = APIRouter( @router.get( - "", + "/", summary="Get all services", status_code=status.HTTP_200_OK, response_model=ServiceGetServiceResponse, responses={ status.HTTP_200_OK: {"description": "Successful retrieval from database"}, - status.HTTP_401_UNAUTHORIZED: { - "description": "Unauthorized", - "content": { - "application/json": { - "examples": { - "awaiting_approval": { - "summary": "Organisation has not yet been approved." - }, - } - } - }, - }, - status.HTTP_403_FORBIDDEN: { - "description": "Forbidden", - "content": { - "application/json": { - "examples": { - "not_root": {"summary": "Not authorised. Must be root user."}, - } - } - }, - }, + status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"}, }, ) async def get_all_services( @@ -82,7 +61,7 @@ async def get_all_services( @router.post( - "", + "/", summary="Register a new service.", status_code=status.HTTP_200_OK, response_model=ServicePostServiceResponse, @@ -148,7 +127,7 @@ async def regenerate_api_key( @router.delete( - "", + "/", summary="Remove a service.", status_code=status.HTTP_204_NO_CONTENT, responses={ diff --git a/src/user/router.py b/src/user/router.py index a561be4..fe9dbef 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -76,7 +76,7 @@ async def current_user(user_model: user_model_claims_dependency): @router.get( - "", + "/", summary="Get user hub details by ID.", response_model=UserResponse, status_code=status.HTTP_200_OK, @@ -95,7 +95,7 @@ async def get_user_by_id( @router.delete( - "", + "/", summary="Delete user from hub by ID.", status_code=status.HTTP_204_NO_CONTENT, responses={ diff --git a/src/utils.py b/src/utils.py index e3bdb25..5f1df8d 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,8 +1,9 @@ from datetime import datetime, timezone from joserfc import jwt, jwk, errors +from src.auth.exceptions import UnauthorizedException from src.config import settings -from src.exceptions import ForbiddenException, UnauthorizedException + KEY = jwk.import_key(settings.SECRET_KEY.get_secret_value(), "oct") @@ -32,7 +33,7 @@ async def verify_email_token(user_model, token): raise UnauthorizedException("Invitation expired.") if user_model.email != claimed_email: - raise ForbiddenException("The logged in user and email do not match.") + raise UnauthorizedException("The logged in user and email do not match.") return email_claims diff --git a/test/test_auth_approval.py b/test/test_auth_approval.py index 3d6c4f8..8bc73d9 100644 --- a/test/test_auth_approval.py +++ b/test/test_auth_approval.py @@ -138,7 +138,7 @@ async def test_patch_org_contact_auth_approval(default_client: AsyncClient): @pytest.mark.anyio async def test_get_service_auth_approval(default_client: AsyncClient): - resp = await default_client.get("/service?org_id=1") + resp = await default_client.get("/service/?org_id=1") assert resp.status_code != 422 assert "has not been approved." in resp.json()["detail"] diff --git a/test/test_auth_root.py b/test/test_auth_root.py index 9c168e4..c53b42c 100644 --- a/test/test_auth_root.py +++ b/test/test_auth_root.py @@ -46,7 +46,7 @@ def add_second_org(db_session): async def test_get_org_auth_root(no_su_client: AsyncClient): resp = await no_su_client.get("/org?org_id=2") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -65,7 +65,7 @@ async def test_patch_org_questionnaire_auth_root(no_su_client: AsyncClient): }, ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -73,7 +73,7 @@ async def test_patch_org_questionnaire_auth_root(no_su_client: AsyncClient): async def test_get_org_users_auth_root(no_su_client: AsyncClient): resp = await no_su_client.get("/org/users?org_id=2") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -81,7 +81,7 @@ async def test_get_org_users_auth_root(no_su_client: AsyncClient): async def test_get_org_groups_auth_root(no_su_client: AsyncClient): resp = await no_su_client.get("/org/groups?org_id=2") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -89,7 +89,7 @@ async def test_get_org_groups_auth_root(no_su_client: AsyncClient): async def test_get_org_contact_auth_root(no_su_client: AsyncClient): resp = await no_su_client.get("/org/contact?org_id=2&contact_type=billing") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -104,15 +104,15 @@ async def test_patch_org_contact_auth_root(no_su_client: AsyncClient): }, ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @pytest.mark.anyio async def test_get_service_auth_root(no_su_client: AsyncClient): - resp = await no_su_client.get("/service?org_id=2") + resp = await no_su_client.get("/service/?org_id=2") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -120,7 +120,7 @@ async def test_get_service_auth_root(no_su_client: AsyncClient): async def test_get_iam_group_permissions_auth_root(no_su_client: AsyncClient): resp = await no_su_client.get("/iam/group/permissions?org_id=2&group_id=1") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -128,7 +128,7 @@ async def test_get_iam_group_permissions_auth_root(no_su_client: AsyncClient): async def test_get_iam_group_users_auth_root(no_su_client: AsyncClient): resp = await no_su_client.get("/iam/group/users?org_id=2&group_id=1") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -138,7 +138,7 @@ async def test_post_iam_group_auth_root(no_su_client: AsyncClient): "/iam/group", json={"name": "New Group", "organisation_id": 2} ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -153,7 +153,7 @@ async def test_put_iam_group_permission_auth_root( json={"permission_id": 1, "group_id": 2, "organisation_id": 2}, ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -173,7 +173,7 @@ async def test_put_iam_group_user_auth_root(no_su_client: AsyncClient, db_sessio "/iam/group/user", json={"user_id": 2, "group_id": 1, "organisation_id": 2} ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -181,7 +181,7 @@ async def test_put_iam_group_user_auth_root(no_su_client: AsyncClient, db_sessio async def test_get_iam_permissions_auth_root(no_su_client: AsyncClient): resp = await no_su_client.get("/iam/permissions?org_id=2") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] @@ -191,5 +191,5 @@ async def test_post_iam_permissions_search_auth_root(no_su_client: AsyncClient): "/iam/permissions/search", json={"organisation_id": 2, "action": "read"} ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be the org's root user" in resp.json()["detail"] diff --git a/test/test_auth_su.py b/test/test_auth_su.py index 97268bc..2e438fb 100644 --- a/test/test_auth_su.py +++ b/test/test_auth_su.py @@ -18,9 +18,9 @@ pytestmark = [ @pytest.mark.anyio async def test_get_user_auth_su(no_su_client: AsyncClient): - resp = await no_su_client.get("/user?user_id=1") + resp = await no_su_client.get("/user/?user_id=1") assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Must be super admin" @@ -30,7 +30,7 @@ async def test_patch_org_status_auth_su(no_su_client: AsyncClient): "/org/status", json={"organisation_id": 1, "status": "submitted"} ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Must be super admin" @@ -52,7 +52,7 @@ async def test_patch_org_root_user_auth_su(no_su_client: AsyncClient, db_session "/org/root_user", json={"organisation_id": 1, "user_id": 2} ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Must be super admin" @@ -60,15 +60,15 @@ async def test_patch_org_root_user_auth_su(no_su_client: AsyncClient, db_session async def test_patch_service_key_auth_su(no_su_client: AsyncClient): resp = await no_su_client.patch("/service/key", json={"service_id": 1}) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Must be super admin" @pytest.mark.anyio async def test_post_service_auth_su(no_su_client: AsyncClient): - resp = await no_su_client.post("/service", json={"name": "New Test Service"}) + resp = await no_su_client.post("/service/", json={"name": "New Test Service"}) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Must be super admin" @@ -79,7 +79,7 @@ async def test_post_perm_auth_su(no_su_client: AsyncClient, db_session): json={"service_id": 1, "resource": "test_resource", "action": "create"}, ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Must be super admin" @@ -99,5 +99,5 @@ async def test_post_org_user_auth_su(no_su_client: AsyncClient, db_session): "/org/user", json={"organisation_id": 1, "user_id": 2} ) assert resp.status_code != 422 - assert resp.status_code == 403 + assert resp.status_code == 401 assert "Must be super admin" in resp.json()["detail"] diff --git a/test/test_iam.py b/test/test_iam.py index 85e5631..ab3b0df 100644 --- a/test/test_iam.py +++ b/test/test_iam.py @@ -205,7 +205,7 @@ async def test_get_group_permissions_mismatch( db_session.flush() resp = await default_client.get(f"/iam/group/permissions?{query}") - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Group does not belong to this organization" @@ -271,7 +271,7 @@ async def test_get_group_users_mismatch( db_session.flush() resp = await default_client.get(f"/iam/group/users?{query}") - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Group does not belong to this organization" @@ -453,7 +453,7 @@ async def test_put_group_perm_mismatch( db_session.flush() resp = await default_client.put("/iam/group/permission", json=body) - assert resp.status_code == 403 + assert resp.status_code == 401 assert resp.json()["detail"] == "Group does not belong to this organization" @@ -785,7 +785,7 @@ async def test_post_perm_search_status_checks( @pytest.mark.anyio async def test_delete_group_permissions_success(default_client: AsyncClient): resp = await default_client.delete( - "/iam/group/permission?org_id=1&group_id=1&perm_id=1" + "/iam/group/permissions?org_id=1&group_id=1&perm_id=1" ) data = resp.json() diff --git a/test/test_service.py b/test/test_service.py index 0114748..1e19a40 100644 --- a/test/test_service.py +++ b/test/test_service.py @@ -15,7 +15,7 @@ pytestmark = [ @pytest.mark.anyio async def test_get_services_success(default_client: AsyncClient): - resp = await default_client.get("/service?org_id=1") + resp = await default_client.get("/service/?org_id=1") data = resp.json() assert resp.status_code == 200 @@ -32,14 +32,14 @@ async def test_get_services_success(default_client: AsyncClient): async def test_get_services_status_checks( default_client: AsyncClient, query: str, expected_status: int ): - resp = await default_client.get(f"/service?{query}") + resp = await default_client.get(f"/service/?{query}") assert resp.status_code == expected_status @pytest.mark.anyio async def test_post_service_success(default_client: AsyncClient): - resp = await default_client.post("/service", json={"name": "New Test Service"}) + resp = await default_client.post("/service/", json={"name": "New Test Service"}) data = resp.json() assert resp.status_code == 200 @@ -62,7 +62,7 @@ async def test_post_service_success(default_client: AsyncClient): async def test_post_service_status_checks( default_client: AsyncClient, body: dict[str, str], expected_status: int ): - resp = await default_client.post("/service", json=body) + resp = await default_client.post("/service/", json=body) assert resp.status_code == expected_status @@ -100,6 +100,6 @@ async def test_patch_services_status_checks( @pytest.mark.anyio async def test_delete_service_success(default_client: AsyncClient): - resp = await default_client.delete("/service?service_id=1") + resp = await default_client.delete("/service/?service_id=1") assert resp.status_code == 204 diff --git a/test/test_user.py b/test/test_user.py index b5926cd..ab87aef 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -32,7 +32,7 @@ async def test_get_self_db_success(default_client: AsyncClient): @pytest.mark.anyio async def test_get_user_success(default_client: AsyncClient): - resp = await default_client.get("/user?user_id=1") + resp = await default_client.get("/user/?user_id=1") data = resp.json() assert resp.status_code == 200 @@ -52,14 +52,14 @@ async def test_get_user_success(default_client: AsyncClient): async def test_get_user_status_checks( default_client: AsyncClient, query: str, expected_status: int ): - resp = await default_client.get(f"/user?{query}") + resp = await default_client.get(f"/user/?{query}") assert resp.status_code == expected_status @pytest.mark.anyio async def test_delete_user_success(default_client: AsyncClient): - resp = await default_client.delete("/user?user_id=1") + resp = await default_client.delete("/user/?user_id=1") assert resp.status_code == 204