diff --git a/src/iam/router.py b/src/iam/router.py index 810c2aa..55971f6 100644 --- a/src/iam/router.py +++ b/src/iam/router.py @@ -2,20 +2,18 @@ Router endpoints for IAM Endpoints: -- [POST](/api/v1/iam/can_act_on_resource): [API Key & User JWT]: Used for services to check user access permission -- [GET](/api/v1/iam/group/permissions): [Root User]: Gets a list of permissions granted to a group -- [GET](/api/v1/iam/group/users): [Root User]: Gets a list of users assigned to a group -- [POST](/api/v1/iam/group): [Root User]: Creates a new group -- [PUT](/api/v1/iam/group/permission): [Root User]: Grants a permission to a group -- [PUT](/api/v1/iam/group/user): [Root User]: Directly adds a user to the group -- [DELETE](/api/v1/iam/group/permissions): [Root User]: Removes a permission from the group -- [DELETE](/api/v1/iam/group/user): [Root User]: Removes a user from the group -- [GET](/api/v1/iam/permissions): [Root User]: Returns a full list of permissions -- [POST](/api/v1/iam/permission): [Super Admin]: Creates a new permission for a service -- [DELETE](/api/v1/iam/permission): [Super Admin]: Deletes a permission -- [POST](/api/v1/iam/permissions/search): [Root User]: Search list of permissions -- [PUT](/api/v1/iam/group/user/invitation): [Root User]: Send an email invitation for non-org member to join a group -- [PUT](/api/v1/iam/group/user//invitation/accept): [User Claim & Invite JWT]: Accept email invitation to join an org's group + - [POST](/iam/can_act_on_resource): [API key & user claim]: Service access point to verify user permissions + - [GET](/iam/group/permissions): [root user]: Gets list of perms(service, resource, action) the given group(id) has + - [DELETE](/iam/group/permissions): [root user]: Removes a given perm(id) from the given group(id) + - [GET](/iam/group/users): [root user]: Gets a list of users(id, name, email) that are assigned to the given group(id) + - [POST](/iam/group): [root user]: Creates a new group for the given org(id) + - [PUT](/iam/group/permission): [root user]: Assigns a perm(id) to the given group(id) + - [PUT](/iam/group/user): [root user]: Assigns a user(id) to a group(id) + - [DELETE](/iam/group/user): [root user]: Removes a user(id) from the given group(id) + - [GET](/iam/permissions): [root user]: Gets a list of all permissions + - [POST](/iam/permission): [super admin]: Creates a new permission + - [DELETE](/iam/permission): [super admin]: Removes a permission + - [GET](/iam/permissions/search): [root user]: Returns a list of permissions matching a filter(service|resource|action) """ from fastapi import APIRouter, status, BackgroundTasks @@ -283,21 +281,7 @@ async def add_group_permission( return response -@router.put( - path="/group/user", - summary="Directly adds a user to the group", - status_code=status.HTTP_200_OK, - response_model=IAMPutGroupUserResponse, - responses={ - status.HTTP_401_UNAUTHORIZED: { - "description": "Group not in org | User not authenticated | User does not have permission" - }, - status.HTTP_409_CONFLICT: {"description": "User is already in group"}, - status.HTTP_403_FORBIDDEN: { - "description": "Only existing org members can be added directly." - }, - }, -) +@router.put("/group/user", response_model=IAMPutGroupUserResponse) async def add_group_user( db: db_dependency, group_model: group_model_body_dependency, @@ -305,11 +289,6 @@ async def add_group_user( org_model: org_model_root_claim_body_dependency, request_model: IAMPutGroupUserRequest, ): - """ - Directly adds an organisation member to a group.\n - To add a non-member, use an email invitation instead.\n - The user's email address must match the email on their OIDC profile. - """ if group_model.org_id != org_model.id: raise UnauthorizedException("Group does not belong to this organization") @@ -330,26 +309,13 @@ async def add_group_user( return response -@router.delete( - path="/group/permissions", - summary="Removes a permission from the group", - status_code=status.HTTP_200_OK, - response_model=IAMDeleteGroupPermissionResponse, - responses={ - status.HTTP_401_UNAUTHORIZED: { - "description": "Group not in org | User not authenticated | User does not have permission" - }, - }, -) +@router.delete("/group/permissions") async def remove_group_permissions( db: db_dependency, group_model: group_model_query_dependency, perm_model: perm_model_query_dependency, org_model: org_model_root_claim_query_dependency, ): - """ - Removes a permission from the group. - """ if group_model.org_id != org_model.id: raise UnauthorizedException("Group does not belong to this organization") @@ -363,26 +329,13 @@ async def remove_group_permissions( return response -@router.delete( - path="/group/user", - summary="Removes a user from the group", - status_code=status.HTTP_200_OK, - response_model=IAMDeleteGroupUserResponse, - responses={ - status.HTTP_401_UNAUTHORIZED: { - "description": "Group not in org | User not authenticated | User does not have permission" - }, - }, -) +@router.delete("/group/user") async def remove_group_user( db: db_dependency, group_model: group_model_query_dependency, user_model: user_model_query_dependency, org_model: org_model_root_claim_query_dependency, ): - """ - Removes a user from the group. - """ if group_model.org_id != org_model.id: raise UnauthorizedException("Group does not belong to this organization") @@ -396,47 +349,21 @@ async def remove_group_user( return response -@router.get( - path="/permissions", - summary="Returns a full list of permissions", - status_code=status.HTTP_200_OK, - response_model=IAMGetPermissionsResponse, - responses={ - status.HTTP_401_UNAUTHORIZED: { - "description": "User must be root user of an organisation." - }, - }, -) +@router.get("/permissions", response_model=IAMGetPermissionsResponse) async def get_permissions( db: db_dependency, org_model: org_model_root_claim_query_dependency ): - """ - Returns a full list of permissions. - """ permission_models = db.query(Perm).all() return {"permissions": permission_models} -@router.post( - path="/permission", - summary="Creates a new permission for a service", - status_code=status.HTTP_201_CREATED, - response_model=IAMPostPermissionResponse, - responses={ - status.HTTP_401_UNAUTHORIZED: {"description": "Must be super user."}, - status.HTTP_404_NOT_FOUND: {"description": "Service does not exist"}, - status.HTTP_409_CONFLICT: {"description": "Permission already exists"}, - }, -) +@router.post("/permission", response_model=IAMPostPermissionResponse) async def create_new_permission( db: db_dependency, su: super_admin_dependency, request_model: IAMPostPermissionRequest, ): - """ - 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) @@ -460,40 +387,22 @@ async def create_new_permission( return {"permission": response} -@router.delete( - path="/permission", - summary="Deletes a permission", - status_code=status.HTTP_204_NO_CONTENT, - responses={}, -) +@router.delete("/permission", status_code=status.HTTP_204_NO_CONTENT) async def delete_permission( db: db_dependency, su: super_admin_dependency, perm_model: perm_model_query_dependency, ): - """ - Allows a super admin to remove a permission. - """ db.delete(perm_model) db.commit() -@router.post( - path="/permissions/search", - summary="Search list of permissions", - status_code=status.HTTP_200_OK, - response_model=IAMGetPermissionsSearchResponse, - responses={}, -) +@router.post("/permissions/search", response_model=IAMGetPermissionsSearchResponse) async def post_permissions( db: db_dependency, org_model: org_model_root_claim_body_dependency, request_model: IAMGetPermissionsSearchRequest, ): - """ - Returns a list of permissions filtered by the queries provided.\n - If a query is null, it will be ignored. - """ permission_query = db.query(Perm) if request_model.service_id is not None: @@ -515,10 +424,9 @@ async def post_permissions( @router.put( - path="/group/user/invitation", + "/group/user/invitation", summary="Send an email invitation for non-org member to join a group", status_code=status.HTTP_200_OK, - responses={}, ) async def invitation( background_tasks: BackgroundTasks, @@ -526,17 +434,11 @@ async def invitation( group_model: group_model_body_dependency, request_model: IAMPutGroupInvitationRequest, ): - """ - Sends an email invitation to join a group.\n - This is intended for inviting no-members to a group, giving them permission to access org resources.\n - i.e. Allowing somebody in a partner organisation to view metrics.\n - Can also be used for inviting organisaion members if needed. - """ - org_id: int = org_model.id - org_name: str = org_model.name + org_id = org_model.id + org_name = org_model.name user_email = request_model.user_email - group_id: int = group_model.id - group_name: str = group_model.name + group_id = group_model.id + group_name = group_model.name background_tasks.add_task( send_user_group_invitation, @@ -551,42 +453,32 @@ async def invitation( @router.put( - path="/group/user//invitation/accept", + "/group/user//invitation/accept", summary="Accept email invitation to join an org's group", status_code=status.HTTP_200_OK, - responses={ - status.HTTP_404_NOT_FOUND: {"description": "User|Org|Group not found"}, - status.HTTP_403_FORBIDDEN: { - "description": "Group and organisation do not match" - }, - status.HTTP_409_CONFLICT: {"description": "User is already in the group"}, - }, ) async def accept_invitation( db: db_dependency, user_model: user_model_claims_dependency, request_model: IAMPutGroupInvitationAcceptRequest, ): - """ - Accepts an invitation to join an org's group - """ email_claims = await verify_email_token( token=request_model.jwt, user_model=user_model ) org_model = db.get(Org, email_claims["org_id"]) if org_model is None: - raise OrgNotFoundException(email_claims["org_id"]) + raise OrgNotFoundException() group_model = db.get(Group, email_claims["group_id"]) if group_model is None: - raise GroupNotFoundException(email_claims["group_id"]) + raise GroupNotFoundException() if group_model not in org_model.group_rel: - raise ForbiddenException("Group and org do not match.") + raise UnauthorizedException("Group and org do not match.") if user_model in group_model.user_rel: - raise ConflictException("User already in group.") + raise ConflictException(message="User already in group.") group_model.user_rel.append(user_model) db.commit() diff --git a/src/main.py b/src/main.py index bf671db..26dc6e0 100644 --- a/src/main.py +++ b/src/main.py @@ -34,17 +34,13 @@ tags_metadata = [ "name": "User", "description": "User related operations, includes getting information about the current user", }, - { - "name": "Organisation", - "description": "Organisation related operations, includes getting lists of users etc associated with orgs", - }, { "name": "Service", "description": "Services related operations, includes registering services and reissuing API keys", }, { - "name": "IAM", - "description": "Operations related to the role based identity and access management system. This includes management of groups, permissions, and related users.", + "name": "Organisation", + "description": "Organisation related operations, includes getting lists of users etc associated with orgs", }, ] diff --git a/src/organisation/router.py b/src/organisation/router.py index 10f3521..5c862ea 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -16,7 +16,6 @@ Endpoints: - [PATCH](/org/contact): [root user]: Updates the (contact_type) contact for an org(id). Any number of details can be changed. """ -from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, status @@ -30,7 +29,6 @@ from src.contact.models import Contact from src.contact.schemas import ContactAddress from src.contact.exceptions import ContactNotFoundException from src.database import db_dependency -from src.organisation.schemas_questionnaires import QuestionnaireQuestionsVersion0 from src.user.dependencies import ( user_model_body_dependency, user_model_claims_dependency, @@ -66,7 +64,6 @@ from src.organisation.schemas import ( OrgPatchRootResponse, Questionnaire, OrgPatchContactResponse, - QuestionnaireMetadata, ) router = APIRouter( @@ -91,9 +88,7 @@ router = APIRouter( }, }, ) -async def get_org_by_id( - db: db_dependency, org_model: org_model_root_claim_query_dependency -): +async def get_org_by_id(org_model: org_model_root_claim_query_dependency): """ Returns organisation details including key member email addresses """ @@ -148,21 +143,10 @@ async def create_org( ALl organisations are given the "partial" status on creation. See update_questionnaire() for more details. """ if request_model.intake_questionnaire: - questionnaire_questions = request_model.intake_questionnaire.model_dump() + intake_questionnaire = request_model.intake_questionnaire.model_dump() else: - questionnaire_questions = QuestionnaireQuestionsVersion0().model_dump() - - questionnaire_metadata = QuestionnaireMetadata(version=0, submission_date=None) - - intake_questionnaire = Questionnaire( - metadata=questionnaire_metadata, - questions=questionnaire_questions, - ) - - org_model = Org( - name=request_model.name, - intake_questionnaire=intake_questionnaire.model_dump(mode="json"), - ) + intake_questionnaire = None + org_model = Org(name=request_model.name, intake_questionnaire=intake_questionnaire) org_model.status = "partial" @@ -220,27 +204,18 @@ async def update_questionnaire( final "are you sure" check before setting the org to be in "submitted" status, awaiting admin approval. """ update_data = request_model.intake_questionnaire.model_dump(exclude_none=True) - questionnaire = org_model.intake_questionnaire - questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"]) + questionnaire_model = Questionnaire(**org_model.intake_questionnaire) for key, value in update_data.items(): - if hasattr(questions_model, key): - setattr(questions_model, key, value) + if hasattr(questionnaire_model, key): + setattr(questionnaire_model, key, value) else: raise UnprocessableContentException("Invalid keys in update request") - metadata = QuestionnaireMetadata(version=questionnaire["metadata"]["version"]) - # Allows for partially completed questionnaires to be saved without being submitted for review if not request_model.partial: org_model.status = "submitted" - metadata.submission_date = datetime.now(timezone.utc) - questionnaire_model = Questionnaire( - metadata=metadata, - questions=questions_model, - ) - - org_model.intake_questionnaire = questionnaire_model.model_dump(mode="json") + org_model.intake_questionnaire = questionnaire_model.model_dump() db.flush() response = OrgPatchQuestionnaireResponse(**org_model.__dict__) db.commit() diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index 4641118..b4e9f23 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -7,7 +7,6 @@ Models follow the nomenclature of: """ from typing import Optional -from datetime import datetime from pydantic import EmailStr, ConfigDict @@ -22,20 +21,12 @@ from src.schemas import ( from src.contact.schemas import ContactModel from src.organisation.constants import Status, ContactType -from src.organisation.schemas_questionnaires import ( - QuestionnaireQuestionsVersion0 as CurrentQuestions, - questionnaire_union, -) - - -class QuestionnaireMetadata(CustomBaseModel): - version: int - submission_date: Optional[datetime] = None class Questionnaire(CustomBaseModel): - metadata: QuestionnaireMetadata - questions: questionnaire_union + question_one: Optional[str] = None + question_two: Optional[str] = None + question_three: Optional[str] = None class ContactSummary(CustomBaseModel): @@ -56,7 +47,7 @@ class OrgSchema(OrgIDMixin): class OrgPostOrgRequest(CustomBaseModel): name: str - intake_questionnaire: Optional[CurrentQuestions] = None + intake_questionnaire: Optional[Questionnaire] = None class OrgPostOrgResponse(CustomBaseModel): @@ -66,7 +57,7 @@ class OrgPostOrgResponse(CustomBaseModel): class OrgPatchQuestionnaireRequest(OrgIDMixin): - intake_questionnaire: CurrentQuestions + intake_questionnaire: Questionnaire partial: bool diff --git a/src/organisation/schemas_questionnaires.py b/src/organisation/schemas_questionnaires.py deleted file mode 100644 index 491dfe5..0000000 --- a/src/organisation/schemas_questionnaires.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Optional - -from src.schemas import CustomBaseModel - - -class QuestionnaireQuestions(CustomBaseModel): - pass - - -class QuestionnaireQuestionsVersion0(QuestionnaireQuestions): - question_one: Optional[str] = None - question_two: Optional[str] = None - question_three: Optional[str] = None - - -questionnaire_union = QuestionnaireQuestionsVersion0 # | QuestionnaireQuestionsVersion1 diff --git a/src/user/router.py b/src/user/router.py index fe9dbef..9a3b78a 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -17,8 +17,6 @@ from src.user.schemas import ( UserPostInvitationRequest, UserPostInvitationAcceptRequest, UserGetSelfOrgsResponse, - UserPostInvitationResponse, - UserPostInvitationAcceptResponse, ) from src.user.dependencies import ( user_model_claims_dependency, @@ -155,7 +153,6 @@ async def get_user_orgs(user_model: user_model_claims_dependency): "/invitation", summary="Send an email invitation for a user to join an org", status_code=status.HTTP_200_OK, - response_model=UserPostInvitationResponse, ) async def invitation( background_tasks: BackgroundTasks, @@ -170,19 +167,13 @@ async def invitation( send_invitation, org_id=org_id, org_name=org_name, user_email=user_email ) - response = { - "organisation": org_model, - "invited_email": user_email, - } - - return response + return "Invitation sent" @router.post( "/invitation/accept", summary="Accept email invitation to join an org", status_code=status.HTTP_200_OK, - response_model=UserPostInvitationAcceptResponse, ) async def accept_invitation( db: db_dependency, @@ -198,13 +189,6 @@ async def accept_invitation( raise OrgNotFoundException() org_model.user_rel.append(user_model) - db.flush() - - response = { - "organisation": org_model, - "user": user_model, - } - db.commit() - return response + return "Invitation accepted" diff --git a/src/user/schemas.py b/src/user/schemas.py index 65ab530..ddff869 100644 --- a/src/user/schemas.py +++ b/src/user/schemas.py @@ -6,7 +6,7 @@ from typing import Optional from pydantic import EmailStr from src.organisation.schemas import OrgSchema -from src.schemas import CustomBaseModel, OrgIDMixin, OrgSummary, UserSummary +from src.schemas import CustomBaseModel, OrgIDMixin class OIDCClaims(CustomBaseModel): @@ -60,13 +60,3 @@ class UserPostInvitationAcceptRequest(CustomBaseModel): class UserGetSelfOrgsResponse(CustomBaseModel): organisations: list[OrgSchema] - - -class UserPostInvitationResponse(CustomBaseModel): - organisation: OrgSummary - invited_email: EmailStr - - -class UserPostInvitationAcceptResponse(CustomBaseModel): - organisation: OrgSummary - user: UserSummary diff --git a/test/conftest.py b/test/conftest.py index 1db0ad5..05c8174 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -103,10 +103,7 @@ def _seed(db): owner_contact_id=2, security_contact_id=3, status="approved", - intake_questionnaire={ - "metadata": {"version": 0, "submission_date": None}, - "questions": {"question_two": "answer two"}, - }, + intake_questionnaire={"question_two": "answer two"}, ) ) db.add(Service(name="Test Service", api_key="123456789")) @@ -170,25 +167,11 @@ def get_testable_routes(): if method in {"HEAD", "OPTIONS"}: continue - routes.append( - ( - method, - route.path, - route.status_code, - route.response_model, - route.summary, - ) - ) + routes.append((method, route.path, route.status_code, route.response_model)) return routes # with open("endpoints.txt", "w") as f: # for ep in get_testable_routes(): -# f.write(f"[{ep[0]}]({ep[1]}) -> {ep[2]}: {ep[3]}\n") -# -# -### Docstring formatted output ### -# with open("endpoints.txt", "w") as f: -# for ep in get_testable_routes(): -# f.write(f"- [{ep[0]}]({ep[1]}): []: {ep[4]}\n") +# f.write(f"[{ep[0]}]{ep[1]}({ep[2]}) -> {ep[2]}: {ep[3]}\n") diff --git a/test/test_auth_general.py b/test/test_auth_general.py index a7f73a6..4cc9ba5 100644 --- a/test/test_auth_general.py +++ b/test/test_auth_general.py @@ -32,10 +32,7 @@ async def test_get_org_auth_root_su(default_client: AsyncClient, db_session): owner_contact_id=2, security_contact_id=3, status="approved", - intake_questionnaire={ - "metadata": {"version": 0, "submission_date": None}, - "questions": {}, - }, + intake_questionnaire={}, ) ) db_session.flush() diff --git a/test/test_iam.py b/test/test_iam.py index ab3b0df..5364e87 100644 --- a/test/test_iam.py +++ b/test/test_iam.py @@ -587,7 +587,7 @@ async def test_post_perm_success(default_client: AsyncClient, db_session): "/iam/permission", json={"service_id": 1, "resource": "test_resource", "action": "create"}, ) - assert resp.status_code == 201 + assert resp.status_code == 200 data = resp.json() diff --git a/test/test_organisation.py b/test/test_organisation.py index ca7183c..364c753 100644 --- a/test/test_organisation.py +++ b/test/test_organisation.py @@ -103,13 +103,9 @@ async def test_patch_org_questionnaire_partial_success( assert data["status"] == "partial" assert "intake_questionnaire" in data assert isinstance(data["intake_questionnaire"], dict) - metadata = data["intake_questionnaire"]["metadata"] - assert metadata["version"] == 0 - assert metadata["submission_date"] is None - questions = data["intake_questionnaire"]["questions"] - assert questions["question_one"] == "new answer one" - assert questions["question_two"] == "answer two" - assert questions["question_three"] is None + assert data["intake_questionnaire"]["question_one"] == "new answer one" + assert data["intake_questionnaire"]["question_two"] == "answer two" + assert data["intake_questionnaire"]["question_three"] is None @pytest.mark.parametrize( @@ -176,13 +172,9 @@ async def test_patch_org_questionnaire_submit_success( assert data["status"] == "submitted" assert "intake_questionnaire" in data assert isinstance(data["intake_questionnaire"], dict) - metadata = data["intake_questionnaire"]["metadata"] - assert metadata["version"] == 0 - assert metadata["submission_date"] is not None - questions = data["intake_questionnaire"]["questions"] - assert questions["question_one"] == "new answer one" - assert questions["question_two"] == "answer two" - assert questions["question_three"] is None + assert data["intake_questionnaire"]["question_one"] == "new answer one" + assert data["intake_questionnaire"]["question_two"] == "answer two" + assert data["intake_questionnaire"]["question_three"] is None @pytest.mark.parametrize( diff --git a/test/test_user.py b/test/test_user.py index ab87aef..ad924f7 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -70,15 +70,7 @@ async def test_post_user_invitation_success(default_client: AsyncClient): resp = await default_client.post("/user/invitation", json=body) assert resp.status_code == 200 - data = resp.json() - assert "organisation" in data - assert isinstance(data["organisation"], dict) - assert data["organisation"]["id"] == 1 - assert data["organisation"]["name"] == "Test Org" - - assert "invited_email" in data - assert isinstance(data["invited_email"], str) - assert data["invited_email"] == "admin@test.com" + assert resp.json() == "Invitation sent" @pytest.mark.parametrize( @@ -169,12 +161,9 @@ async def test_get_self_orgs_dynamic(default_client: AsyncClient): "security_contact": {"email": "security@test.org", "id": 3}, "billing_contact": {"email": "billing@test.org", "id": 1}, "intake_questionnaire": { - "questions": { - "question_one": None, - "question_three": None, - "question_two": "answer two", - }, - "metadata": {"version": 0, "submission_date": None}, + "question_one": None, + "question_three": None, + "question_two": "answer two", }, } ]