diff --git a/src/iam/models.py b/src/iam/models.py index a06ff79..c9ccc51 100644 --- a/src/iam/models.py +++ b/src/iam/models.py @@ -42,7 +42,9 @@ class Permission(Base): ), ) - service_rel = relationship("Service", foreign_keys="Permission.service_id") + service_rel = relationship( + "Service", back_populates="permission_rel", foreign_keys="Permission.service_id" + ) @property def service_name(self): diff --git a/src/iam/router.py b/src/iam/router.py index 69073ea..5d824eb 100644 --- a/src/iam/router.py +++ b/src/iam/router.py @@ -23,6 +23,7 @@ from sqlalchemy.exc import IntegrityError from psycopg.errors import UniqueViolation from src.iam.exceptions import GroupNotFoundException +from src.organisation.dependencies import org_model_body_dependency from src.organisation.exceptions import OrgNotFoundException from src.schemas import GroupSummary, OrgSummary, ResourceName from src.service.dependencies import service_model_body_dependency @@ -82,6 +83,8 @@ from src.iam.schemas import ( IAMCAoRResponse, IAMPutGroupInvitationAcceptResponse, IAMPutGroupInvitationResponse, + IAMPutOrgPermissionsRequest, + IAMPutOrgPermissionsResponse, ) from src.utils import verify_email_token @@ -547,7 +550,7 @@ async def delete_permission( response_model=IAMGetPermissionsSearchResponse, responses={}, ) -async def post_permissions( +async def permissions_search( db: db_dependency, org_model: org_model_root_claim_body_dependency, request_model: IAMGetPermissionsSearchRequest, @@ -672,3 +675,36 @@ async def accept_invitation( db.commit() return response + + +@router.put( + path="/org/permissions", + summary="Grants an org access to permissions", + status_code=status.HTTP_200_OK, + response_model=IAMPutOrgPermissionsResponse, + responses={ + status.HTTP_401_UNAUTHORIZED: {"description": "Must be super user."}, + }, +) +async def add_org_permissions( + db: db_dependency, + su: super_admin_dependency, + org_model: org_model_body_dependency, + request_model: IAMPutOrgPermissionsRequest, +): + """ + 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. + """ + for permission in request_model.permissions: + perm_model = db.get(Perm, permission) + + if perm_model not in org_model.permission_rel: + org_model.permission_rel.append(perm_model) + + db.flush() + response = IAMPutOrgPermissionsResponse( + organisation=OrgSummary(**org_model.__dict__), + permissions=org_model.permission_rel, + ) + db.commit() + return response diff --git a/src/iam/schemas.py b/src/iam/schemas.py index d8e526b..8072914 100644 --- a/src/iam/schemas.py +++ b/src/iam/schemas.py @@ -150,3 +150,12 @@ class IAMPutGroupInvitationAcceptResponse(CustomBaseModel): organisation: OrgSummary user: UserSummary group: GroupDetails + + +class IAMPutOrgPermissionsRequest(OrgIDMixin): + permissions: list[int] + + +class IAMPutOrgPermissionsResponse(CustomBaseModel): + organisation: OrgSummary + permissions: list[PermissionSchema] diff --git a/src/iam/service.py b/src/iam/service.py index 056b39c..e3c8740 100644 --- a/src/iam/service.py +++ b/src/iam/service.py @@ -7,21 +7,19 @@ Exports: from typing import Annotated from datetime import datetime, timedelta, timezone +from fastapi import Request, Depends -from src.service.models import Service from src.database import db_dependency from src.exceptions import UnauthorizedException from src.utils import send_email, generate_jwt - -from src.iam.schemas import IAMCAoRRequest from src.iam.models import Group - -from fastapi import Request, Depends +from src.service.models import Service +from src.service.schemas import HasServiceName def valid_service_key( - db: db_dependency, request: Request, request_model: IAMCAoRRequest + db: db_dependency, request: Request, request_model: HasServiceName ) -> bool: rn = request_model.rn api_key = request.headers.get("X-API-Key", None) diff --git a/src/schemas.py b/src/schemas.py index cb2e742..30f4f90 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -14,13 +14,6 @@ class CustomBaseModel(BaseModel): pass -class ResourceName(CustomBaseModel): - service: str - organisation: str - resource: str - instance: Optional[str] = None - - ### Mixins ### class OrgIDMixin(CustomBaseModel): organisation_id: int = Field(gt=0) @@ -42,6 +35,10 @@ class UserIDMixin(CustomBaseModel): user_id: int = Field(gt=0) +class ServiceNameMixin(CustomBaseModel): + service: str + + class OrgSummary(CustomBaseModel): id: int name: str @@ -60,3 +57,9 @@ class UserSummary(CustomBaseModel): class ServiceSummary(CustomBaseModel): id: int name: str + + +class ResourceName(ServiceNameMixin): + organisation: str + resource: str + instance: Optional[str] = None diff --git a/src/service/models.py b/src/service/models.py index 82bdba1..414de5d 100644 --- a/src/service/models.py +++ b/src/service/models.py @@ -7,6 +7,7 @@ Models: """ from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship from src.database import Base @@ -17,3 +18,5 @@ class Service(Base): id = Column(Integer, primary_key=True) name = Column(String, unique=True) api_key = Column(String, unique=True) + + permission_rel = relationship("Permission", back_populates="service_rel") diff --git a/src/service/router.py b/src/service/router.py index d4954fd..22e8594 100644 --- a/src/service/router.py +++ b/src/service/router.py @@ -18,6 +18,9 @@ from src.auth.dependencies import ( super_admin_dependency, org_model_root_claim_query_dependency, ) +from src.iam.service import service_key_dependency +from src.iam.models import Permission as Perm +from src.service.exceptions import ServiceNotFoundException from src.service.models import Service from src.service.utils import generate_api_key @@ -32,6 +35,8 @@ from src.service.schemas import ( ServiceWithKeySchema, ServicePatchKeyResponse, ServicePatchKeyRequest, + ServicePostPermissionsResponse, + ServicePostPermissionsRequest, ) router = APIRouter( @@ -170,3 +175,66 @@ async def remove_service( """ db.delete(service_model) db.commit() + + +@router.post( + path="/permissions", + summary="Service endpoint for creating its own permissions.", + status_code=status.HTTP_200_OK, + response_model=ServicePostPermissionsResponse, + responses={ + status.HTTP_401_UNAUTHORIZED: { + "description": "API Key missing or invalid | Issue verifying user OIDC claims" + }, + }, +) +async def service_create_new_permissions( + db: db_dependency, + request_model: ServicePostPermissionsRequest, + valid_key: service_key_dependency, +): + """ + Allows a service to register its own set of permissions. + """ + service_model = ( + db.query(Service).filter(Service.name == request_model.rn.service).first() + ) + if service_model is None: + raise ServiceNotFoundException() + else: + service_id = service_model.id + response_list = [] + for new_permission in request_model.permissions: + perm_model = ( + db.query(Perm) + .filter(Perm.service_id == service_id) + .filter(Perm.resource == new_permission.resource) + .filter(Perm.action == new_permission.action) + .first() + ) + if perm_model is not None: + response_code = 409 + response = { + "id": perm_model.id, + "service_name": perm_model.service_name, + "resource": perm_model.resource, + "action": perm_model.action, + } + response_list.append((response, response_code)) + continue + + new_perm_model = Perm(**new_permission.__dict__) + new_perm_model.service_id = service_id + db.add(new_perm_model) + db.flush() + response_code = 201 + response = { + "id": new_perm_model.id, + "service_name": new_perm_model.service_name, + "resource": new_perm_model.resource, + "action": new_perm_model.action, + } + response_list.append((response, response_code)) + + db.commit() + return {"permissions": response_list} diff --git a/src/service/schemas.py b/src/service/schemas.py index 71bb215..544dac3 100644 --- a/src/service/schemas.py +++ b/src/service/schemas.py @@ -6,9 +6,36 @@ Models follow the nomenclature of: - Models: "" ie "ServiceGetServiceResponse" """ -from pydantic import Field +from typing import Generic, TypeVar +from pydantic import Field, ConfigDict -from src.schemas import CustomBaseModel, ServiceIDMixin, ServiceSummary +from src.schemas import ( + CustomBaseModel, + ServiceIDMixin, + ServiceSummary, + ServiceNameMixin, +) + + +T = TypeVar("T", bound=ServiceNameMixin) + + +class HasServiceName(CustomBaseModel, Generic[T]): + rn: T + + +class PermissionResponseSchema(CustomBaseModel): + model_config = ConfigDict(from_attributes=True, extra="ignore") + + id: int + service_name: str + resource: str + action: str + + +class PermissionRequestSchema(CustomBaseModel): + resource: str + action: str class ServiceWithKeySchema(ServiceSummary): @@ -33,3 +60,12 @@ class ServicePatchKeyRequest(ServiceIDMixin): class ServicePatchKeyResponse(CustomBaseModel): service: ServiceWithKeySchema + + +class ServicePostPermissionsRequest(CustomBaseModel): + rn: ServiceNameMixin + permissions: list[PermissionRequestSchema] + + +class ServicePostPermissionsResponse(CustomBaseModel): + permissions: list[tuple[PermissionResponseSchema, int]]