diff --git a/.alembic/env.py b/.alembic/env.py index 87dfd54..ec4697c 100644 --- a/.alembic/env.py +++ b/.alembic/env.py @@ -10,8 +10,6 @@ from src.config import SQLALCHEMY_DATABASE_URI from src.contact.models import Contact from src.organisation.models import Organisation, OrgUsers from src.user.models import User -from src.service.models import Service -from src.iam.models import Permission, Group, GroupPermissions, UserGroups from src.database import Base # this is the Alembic Config object, which provides diff --git a/.alembic/versions/2026-05-22_init_iam.py b/.alembic/versions/2026-05-22_init_iam.py deleted file mode 100644 index 3197d47..0000000 --- a/.alembic/versions/2026-05-22_init_iam.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Init IAM - -Revision ID: a147965e644e -Revises: 8fe51426321d -Create Date: 2026-05-22 15:59:36.469374 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'a147965e644e' -down_revision: Union[str, Sequence[str], None] = '8fe51426321d' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('service', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(), nullable=True), - sa.Column('api_key', sa.String(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('api_key'), - sa.UniqueConstraint('name') - ) - op.create_table('permission', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('resource', sa.String(), nullable=False), - sa.Column('action', sa.String(), nullable=False), - sa.Column('service_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['service_id'], ['service.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('group', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('org_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') - ) - op.create_table('group_permissions', - sa.Column('group_id', sa.Integer(), nullable=False), - sa.Column('permission_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['group_id'], ['group.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['permission_id'], ['permission.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('group_id', 'permission_id') - ) - op.create_table('user_groups', - sa.Column('org_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('group_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['group_id'], ['group.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('org_id', 'user_id', 'group_id') - ) - op.add_column('organisation', sa.Column('root_user_id', sa.Integer(), nullable=True)) - op.create_unique_constraint("organisation_name_key", 'organisation', ['name']) - op.create_foreign_key("organisation_root_user_fkey", 'organisation', 'user', ['root_user_id'], ['id']) - op.drop_column('orgusers', 'is_admin') - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('orgusers', sa.Column('is_admin', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) - op.drop_constraint("organisation_root_user_fkey", 'organisation', type_='foreignkey') - op.drop_constraint("organisation_name_key", 'organisation', type_='unique') - op.drop_column('organisation', 'root_user_id') - op.drop_table('user_groups') - op.drop_table('group_permissions') - op.drop_table('group') - op.drop_table('permission') - op.drop_table('service') - # ### end Alembic commands ### diff --git a/.alembic/versions/2026-05-25_contact_model_changes.py b/.alembic/versions/2026-05-25_contact_model_changes.py deleted file mode 100644 index 8ad1f5a..0000000 --- a/.alembic/versions/2026-05-25_contact_model_changes.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Contact model changes - -Revision ID: 8132c4b88665 -Revises: a147965e644e -Create Date: 2026-05-25 13:09:22.635058 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '8132c4b88665' -down_revision: Union[str, Sequence[str], None] = 'a147965e644e' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('contact', sa.Column('org_id', sa.Integer(), nullable=False)) - op.create_foreign_key(None, 'contact', 'organisation', ['org_id'], ['id'], ondelete='CASCADE') - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'contact', type_='foreignkey') - op.drop_column('contact', 'org_id') - # ### end Alembic commands ### diff --git a/src/admin/router.py b/src/admin/router.py index 13ac897..e6df7de 100644 --- a/src/admin/router.py +++ b/src/admin/router.py @@ -5,9 +5,43 @@ Endpoints: - List: Description - Endpoints: Description """ -from fastapi import APIRouter +from typing import Annotated + +from fastapi import APIRouter, HTTPException +from fastapi.params import Path + +from src.organisation.constants import ContactType +from src.organisation.schemas import OrgContactGetResponse +from src.organisation.models import Organisation as Org +from src.contact.models import Contact + +from src.auth.service import claims_dependency, org_or_super_admin_dependency +from src.database import db_dependency + router = APIRouter( tags=["admin"], prefix="/admin", ) + + +@router.get("/{org_id}/contact/{contact_type}", response_model=OrgContactGetResponse) +async def get_contact(db: db_dependency, user: claims_dependency, is_admin: org_or_super_admin_dependency, contact_type: ContactType, org_id: Annotated[int, Path(gt=0)]): + org_model = db.query(Org).filter(Org.id == org_id).first() + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") + match contact_type: + case "billing": + contact_id = org_model.billing_contact_id + case "security": + contact_id = org_model.security_contact_id + case "owner": + contact_id = org_model.owner_contact_id + case _: + raise HTTPException(status_code=422, detail="Invalid contact type") + + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + return contact_model diff --git a/src/api.py b/src/api.py index bf53dd0..502ed47 100644 --- a/src/api.py +++ b/src/api.py @@ -8,8 +8,6 @@ from src.contact.router import router as contact_router from src.organisation.router import router as organisation_router 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 api_router = APIRouter() @@ -19,8 +17,6 @@ api_router.include_router(contact_router) api_router.include_router(organisation_router) api_router.include_router(user_router) api_router.include_router(admin_router) -api_router.include_router(service_router) -api_router.include_router(iam_router) @api_router.get("/healthcheck", include_in_schema=False) diff --git a/src/auth/router.py b/src/auth/router.py index 5e8871d..5a8636f 100644 --- a/src/auth/router.py +++ b/src/auth/router.py @@ -8,4 +8,54 @@ from fastapi import APIRouter router = APIRouter( tags=["auth"], -) \ No newline at end of file +) + + + + +# oauth = OAuth() +# oauth.register( +# name="oidc", +# server_metadata_url=auth_settings.OIDC_CONFIG, +# client_id=auth_settings.CLIENT_ID, +# client_secret=None, +# code_challenge_method="S256", +# client_kwargs={ +# "code_challenge_method": "S256", +# "scope": "openid profile email", +# } +# ) + + +# @auth_router.get('/login') +# async def login(request: Request): +# redirect_uri = request.url_for('auth') +# return await oauth.oidc.authorize_redirect(request, redirect_uri, code_challenge_method="S256") +# +# +# @auth_router.get('/auth', include_in_schema=False) +# async def auth(db: db_dependency, request: Request): +# token = await oauth.oidc.authorize_access_token(request) +# user = token.get("userinfo") +# request.session["user"] = user +# +# try: +# valid_user = OIDCUser(first_name=user["given_name"], last_name=user["family_name"], email=user["email"], oidc_id=user["sub"]) +# except Exception as e: +# print(e) +# raise HTTPException(status_code=422, detail="Invalid or missing OIDC data") +# +# user_exists = db.query(exists().where(User.oidc_id == valid_user.oidc_id)).scalar() +# +# if not user_exists: +# user_model = User(**valid_user.model_dump()) +# db.add(user_model) +# db.commit() +# +# return RedirectResponse(url="/") +# +# +# @auth_router.get('/logout') +# async def logout(request: Request): +# request.session.pop('user', None) +# return RedirectResponse(url='/') diff --git a/src/auth/service.py b/src/auth/service.py index 6f9cc97..fc3cd4c 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -20,9 +20,7 @@ from sqlalchemy.sql import exists from src.auth.config import auth_settings from src.user.service import add_user_to_db from src.organisation.models import OrgUsers, Organisation as Org -from src.user.models import User from src.database import db_dependency -from src.organisation.dependencies import org_model_dependency oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG) @@ -90,18 +88,31 @@ async def is_org_user(claims: claims_dependency, db: db_dependency, org_id: int org_user_dependency = Annotated[dict[str, Any], Depends(is_org_user)] -async def is_org_root(claims: claims_dependency, db: db_dependency, org_model: org_model_dependency, org_id: int = Path(gt=0)): +async def is_org_admin(claims: claims_dependency, db: db_dependency, org_id: int = Path(gt=0)): + org_exists = db.query(exists().where(Org.id == org_id)).scalar() + if not org_exists: + raise HTTPException(status_code=404, detail="Organisation not found") + db_id = claims.get("db_id", None) if db_id is None: raise HTTPException(status_code=404, detail="User not found in db") - if org_model.root_user_id == db_id: - return db.query(User).filter(User.id == db_id).first() + exists_query = (db.query(OrgUsers) + .filter(OrgUsers.org_id == org_id, + OrgUsers.user_id == db_id, + OrgUsers.is_admin == True + ).exists() + ) - raise HTTPException(status_code=401, detail="Not authorised") + org_admin_exists = db.query(exists_query).scalar() + + if not org_admin_exists: + raise HTTPException(status_code=401, detail="Not authorised") + + return org_admin_exists -root_user_dependency = Annotated[dict[str, Any], Depends(is_org_root)] +org_admin_dependency = Annotated[dict[str, Any], Depends(is_org_admin)] async def is_super_admin(claims: claims_dependency): @@ -117,3 +128,114 @@ async def is_super_admin(claims: claims_dependency): super_admin_dependency = Annotated[dict[str, Any], Depends(is_super_admin)] + + +async def is_admin(claims: claims_dependency, db: db_dependency, org_id: int = Path(gt=0)): + try: + await is_super_admin(claims) + return True + except HTTPException as e: + pass + try: + await is_org_admin(claims, db, org_id) + return True + except HTTPException as e: + raise HTTPException(status_code=401, detail="Not authorised") + +org_or_super_admin_dependency = Annotated[dict[str, Any], Depends(is_admin)] + +# Middleware version of user auth +# import json +# import logging +# +# from threading import Timer +# from urllib.request import urlopen +# from starlette.requests import HTTPConnection, Request +# +# from authlib.jose.rfc7517.jwk import JsonWebKey +# from authlib.jose.rfc7517.key_set import KeySet +# from authlib.oauth2 import OAuth2Error, ResourceProtector +# from authlib.oauth2.rfc6749 import MissingAuthorizationError +# from authlib.oauth2.rfc7523 import JWTBearerTokenValidator +# from authlib.oauth2.rfc7523.validator import JWTBearerToken +# +# from starlette.authentication import ( +# AuthCredentials, +# AuthenticationBackend, +# AuthenticationError, +# SimpleUser, +# ) +# +# logger = logging.getLogger(__name__) +# +# +# class RepeatTimer(Timer): +# def __init__(self, *args, **kwargs) -> None: +# super().__init__(*args, **kwargs) +# self.daemon = True +# +# def run(self): +# while not self.finished.wait(self.interval): +# self.function(*self.args, **self.kwargs) +# +# +# class BearerTokenValidator(JWTBearerTokenValidator): +# def __init__(self, issuer: str, audience: str): +# self._issuer = issuer +# self._jwks_uri: str | None = None +# super().__init__(public_key=self.fetch_key(), issuer=issuer) +# self.claims_options = { +# "exp": {"essential": True}, +# "aud": {"essential": True, "value": audience}, +# "iss": {"essential": True, "value": issuer}, +# } +# self._timer = RepeatTimer(3600, self.refresh) +# self._timer.start() +# +# def refresh(self): +# try: +# self.public_key = self.fetch_key() +# except Exception as exc: +# logger.warning(f"Could not update jwks public key: {exc}") +# +# def fetch_key(self) -> KeySet: +# """Fetch the jwks_uri document and return the KeySet.""" +# response = urlopen(self.jwks_uri) +# logger.debug(f"OK GET {self.jwks_uri}") +# return JsonWebKey.import_key_set(json.loads(response.read())) +# +# @property +# def jwks_uri(self) -> str: +# """The jwks_uri field of the openid-configuration document.""" +# if self._jwks_uri is None: +# config_url = urlopen(f"{self._issuer}/.well-known/openid-configuration") +# config = json.loads(config_url.read()) +# self._jwks_uri = config["jwks_uri"] +# return self._jwks_uri +# +# +# class BearerTokenAuthBackend(AuthenticationBackend): +# def __init__(self, issuer: str, audience: str) -> None: +# rp = ResourceProtector() +# validator = BearerTokenValidator( +# issuer=issuer, +# audience=audience, +# ) +# rp.register_token_validator(validator) +# self.resource_protector = rp +# +# async def authenticate(self, conn: HTTPConnection): +# if "Authorization" not in conn.headers: +# return +# request = Request(conn.scope) +# try: +# token: JWTBearerToken = self.resource_protector.validate_request( +# scopes=["openid"], +# request=request, +# ) +# except (MissingAuthorizationError, OAuth2Error) as error: +# raise AuthenticationError(error.description) from error +# scope: str = token.get_scope() +# scopes = scope.split() +# scopes.append("authenticated") +# return AuthCredentials(scopes=scopes), SimpleUser(username=token["email"]) diff --git a/src/contact/models.py b/src/contact/models.py index e3d0d05..4f94601 100644 --- a/src/contact/models.py +++ b/src/contact/models.py @@ -5,7 +5,7 @@ Models: - Contact: id[pk], email, first_name, last_name, phonenumber, vat_number street_address, post_office_box_number, address_locality, country_code, address_region, postal_code """ -from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy import Column, Integer, String from src.database import Base @@ -27,5 +27,3 @@ class Contact(Base): country_code = Column(String) # Eg GB address_region = Column(String, default=None, nullable=True) postal_code = Column(String) - - org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE"), nullable=False) diff --git a/src/contact/router.py b/src/contact/router.py index 9528fd9..3ed9a0f 100644 --- a/src/contact/router.py +++ b/src/contact/router.py @@ -9,10 +9,110 @@ Endpoints: - [patch]/{contact_id} - Updates the details of an existing contact - [delete]/{contact_id} - Deletes a contact by ID """ -from fastapi import APIRouter +from typing import Annotated +from fastapi import APIRouter, HTTPException +from fastapi.params import Path + +from sqlalchemy import or_ + +from src.contact.schemas import ContactContactGetResponse, ContactAddressGetResponse, ContactContactPostRequest, \ + ContactUpdateRequest, ContactOrgGetResponse +from src.contact.models import Contact + +from src.database import db_dependency +from src.organisation.models import Organisation as Org +from src.organisation.constants import ContactType router = APIRouter( prefix="/contact", tags=["contact"], -) \ No newline at end of file +) + + +@router.get("/{contact_id}", response_model=ContactContactGetResponse) +async def get_contact_details_by_id(contact_id: int, db: db_dependency): + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + return contact_model + + +@router.get("/{contact_id}/address", response_model=ContactAddressGetResponse) +async def get_contact_address_by_id(contact_id: int, db: db_dependency): + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + return contact_model + + +@router.post("/") +async def create_contact(db: db_dependency, contact_request: ContactContactPostRequest): + contact_model = Contact(**contact_request.model_dump()) + + db.add(contact_model) + db.commit() + + +@router.patch("/{contact_id}") +async def update_contact(db: db_dependency, contact_request: ContactUpdateRequest, contact_id: Annotated[int, Path(gt=0)]): + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + update_data = contact_request.model_dump(exclude_none=True) + for key, value in update_data.items(): + if hasattr(contact_model, key): + setattr(contact_model, key, value) + else: + raise HTTPException(status_code=422, detail="Invalid keys in update request") + + db.add(contact_model) + db.commit() + + +@router.delete("/{contact_id}") +async def delete_contact(db: db_dependency, contact_id: Annotated[int, Path(gt=0)]): + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + db.delete(contact_model) + db.commit() + + +@router.get("/{contact_id}/orgs", response_model=list[ContactOrgGetResponse]) +async def get_contact_orgs(db: db_dependency, contact_id: Annotated[int, Path(gt=0)]): + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + org_models = (db.query(Org).filter( + or_( + Org.owner_contact_id == contact_id, + Org.billing_contact_id == contact_id, + Org.security_contact_id == contact_id + ) + ).all()) + + response = [] + + for org in org_models: + types=[] + if org.owner_contact_id == contact_id: + types.append(ContactType.OWNER) + if org.billing_contact_id == contact_id: + types.append(ContactType.BILLING) + if org.security_contact_id == contact_id: + types.append(ContactType.SECURITY) + + org_response_model = ContactOrgGetResponse( + name=str(org.name), + contact_types=types, + ) + response.append(org_response_model) + + + return response diff --git a/src/contact/schemas.py b/src/contact/schemas.py index c3d705c..d64e265 100644 --- a/src/contact/schemas.py +++ b/src/contact/schemas.py @@ -7,24 +7,12 @@ Models: """ from typing import Optional -from pydantic import EmailStr, ConfigDict +from pydantic import EmailStr from src.organisation.constants import ContactType from src.schemas import CustomBaseModel -class ContactAddress(CustomBaseModel): - model_config = ConfigDict(from_attributes=True, extra="ignore") - - post_office_box_number: Optional[str] = None - street_address: Optional[str] = None - street_address_line_2: Optional[str] = None - locality: Optional[str] = None - address_region: Optional[str] = None - country_code: Optional[str] = None - postal_code: Optional[str] = None - - class ContactContactGetResponse(CustomBaseModel): email: str first_name: str diff --git a/src/database.py b/src/database.py index 819faeb..1761560 100644 --- a/src/database.py +++ b/src/database.py @@ -17,16 +17,13 @@ engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value()) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - def get_db(): - db = SessionLocal() - try: - yield db - except: - db.rollback() - raise - finally: - db.close() + with SessionLocal.begin() as db: + try: + yield db + finally: + db.rollback() # Anything not explicitly commited is rolled back + db.close() db_dependency = Annotated[Session, Depends(get_db)] diff --git a/src/iam/config.py b/src/iam/config.py deleted file mode 100644 index 4be170e..0000000 --- a/src/iam/config.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Configurations for - -Configurations: - - List: Description - - Configs: Description -""" \ No newline at end of file diff --git a/src/iam/constants.py b/src/iam/constants.py deleted file mode 100644 index e1df957..0000000 --- a/src/iam/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Constants and error codes for - -Constants: - - List: Description - - Consts: Description -""" \ No newline at end of file diff --git a/src/iam/dependencies.py b/src/iam/dependencies.py deleted file mode 100644 index 7447aaf..0000000 --- a/src/iam/dependencies.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Router dependencies for - -Classes: - - List: Description - - Classes: Description - -Functions: - - List: Description - - Functions: Description -""" \ No newline at end of file diff --git a/src/iam/exceptions.py b/src/iam/exceptions.py deleted file mode 100644 index 5debbb4..0000000 --- a/src/iam/exceptions.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Module specific exceptions for - -Exceptions: - - List: Description - - Exceptions: Description -""" \ No newline at end of file diff --git a/src/iam/models.py b/src/iam/models.py deleted file mode 100644 index 6e9ca70..0000000 --- a/src/iam/models.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Database models for the IAM module - -Models: - - List: Description - - Models: Description -""" -from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint -from sqlalchemy.orm import relationship - -from src.database import Base - - -class Permission(Base): - __tablename__ = "permission" - - id = Column(Integer, primary_key=True) - resource = Column(String, nullable=False) - action = Column(String, nullable=False) - - service_id = Column(Integer, ForeignKey("service.id", ondelete="CASCADE")) - - UniqueConstraint("service_id", "resource", "action", name="uniq_permission_resource_and_action") - - -class Group(Base): - __tablename__ = "group" - id = Column(Integer, primary_key=True) - name = Column(String, nullable=False, unique=True) - - org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE")) - - user_rel = relationship( - "User", - secondary="user_groups", - back_populates="group_rel" - ) - - org_rel = relationship("Organisation", back_populates="group_rel") - - -class GroupPermissions(Base): - __tablename__ = "group_permissions" - group_id = Column(Integer, ForeignKey("group.id", ondelete="CASCADE"), primary_key=True) - permission_id = Column(Integer, ForeignKey("permission.id", ondelete="CASCADE"), primary_key=True) - - -class UserGroups(Base): - __tablename__ = "user_groups" - org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True) - user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True) - group_id = Column(Integer, ForeignKey("group.id", ondelete="CASCADE"), primary_key=True) diff --git a/src/iam/router.py b/src/iam/router.py deleted file mode 100644 index 17e6673..0000000 --- a/src/iam/router.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Router endpoints for - -Endpoints: - - List: Description - - Endpoints: Description -""" -from typing import Annotated, Optional - -from fastapi import APIRouter, Query, HTTPException - -from src.database import db_dependency -from src.schemas import ResourceName -from src.auth.service import claims_dependency -from src.user.models import User -from src.organisation.models import Organisation as Org -from src.service.models import Service -from src.organisation.dependencies import org_model_dependency - -from src.iam.service import service_key_dependency -from src.iam.models import Permission as Perm, GroupPermissions as GPerms, Group, UserGroups - -router = APIRouter( - tags=["IAM"], - prefix="/iam", -) - - -@router.post("/can_act_on_resource") -async def can_act_on_resource(valid_key: service_key_dependency, db: db_dependency, user_claims: claims_dependency, - rn: ResourceName, action: str) -> bool: - try: - user_id = user_claims["db_id"] - rn_org = rn.organisation - rn_service = rn.service - rn_resource = rn.resource - - result = (db.query(Perm) - .join(Service, Service.id == Perm.service_id) - .join(GPerms, GPerms.permission_id == Perm.id) - .join(Group, Group.id == GPerms.group_id) - .join(Org, Org.id == Group.org_id) - .join(UserGroups, UserGroups.group_id == Group.id) - .join(User, User.id == UserGroups.user_id) - .filter(User.id == user_id) - .filter(Org.name == rn_org) - .filter(Service.name == rn_service) - .filter(Perm.resource == rn_resource) - .filter(Perm.action == action) - ).first() - - if result: - return True - else: - return False - except Exception as e: - print(e) - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get("/group/permissions") -async def get_group_permissions(db: db_dependency, group_id: Annotated[int, Query(gt=0)]): - # TODO: iam_admin_dependency - group_perms = db.query(Perm).join(GPerms).filter(GPerms.group_id==group_id).all() - - # TODO: Response model - return group_perms - - -@router.get("/group/users") -async def get_group_users(db: db_dependency, group_id: Annotated[int, Query(gt=0)]): - # TODO: iam_admin_dependency - group_users = db.query(User).join(UserGroups).filter(UserGroups.group_id == group_id).all() - - # TODO: Response model - return group_users - - -@router.post("/group") -async def create_group(db: db_dependency, group_name: str, org_model: org_model_dependency, org_id: int): - # TODO: iam_admin_dependency - # TODO: Request model - group_model = Group(name=group_name, org_id=org_id) - - db.add(group_model) - db.commit() - # TODO: Response model - - -@router.put("/group/permissions") -async def add_group_permissions(db: db_dependency, group_id: int, permission_id: int, org_model: org_model_dependency, org_id: int): - # TODO: iam_admin_dependency - # TODO: Request model - g_perm_model = GPerms(group_id=group_id, permission_id=permission_id) - - db.add(g_perm_model) - db.commit() - # TODO: Response model - - -@router.put("/group/users") -async def add_group_users(db: db_dependency, group_id: int, user_ids: list[int], org_model: org_model_dependency, org_id: int): - # TODO: iam_admin_dependency - # TODO: Request model - for user_id in user_ids: - user_group_model = UserGroups(group_id=group_id, user_id=user_id, org_id=org_id) - db.add(user_group_model) - - db.commit() - # TODO: Response model - - -@router.delete("/group/permissions") -async def remove_group_permissions(db: db_dependency, group_id: int, org_model: org_model_dependency, org_id: int, permission_id: int): - # TODO: iam_admin_dependency - # TODO: Request model - g_perm_model = db.query(GPerms).filter(GPerms.group_id == group_id, GPerms.permission_id == permission_id).first() - if g_perm_model is None: - return - - db.delete(g_perm_model) - db.commit() - return - # TODO: Response model - - -@router.delete("/group/user") -async def remove_group_user(db: db_dependency, group_id: int, user_id: int, org_model: org_model_dependency, org_id: int): - # TODO: iam_admin_dependency - # TODO: Request model - user_group_model = db.query(UserGroups).filter(UserGroups.group_id == group_id, UserGroups.user_id == user_id).first() - if user_group_model is None: - return - - db.delete(user_group_model) - db.commit() - return - # TODO: Response model - - -@router.get("/permissions") -async def get_permissions(db: db_dependency, org_model: org_model_dependency, org_id: int): - # TODO: iam_admin_dependency - # TODO: request model - permission_models = db.query(Perm).all() - - # TODO: Response model - return permission_models - - -@router.post("/permission") -async def create_new_permission(db: db_dependency, service_id: int, resource: str, action: str): - # TODO: super_admin_dependency - perm_model = Perm(service_id=service_id, resource=resource, action=action) - - db.add(perm_model) - db.commit() - - -@router.delete("/permission") -async def delete_permission(db: db_dependency, service_id: int, resource: str, action: str, org_model: org_model_dependency, org_id: int): - # TODO: iam_admin_dependency - # TODO: Request model - perm_model = db.query(Perm).filter(Perm.service_id==service_id, Perm.resource==resource, Perm.action==action).first() - if perm_model is None: - return - - db.delete(perm_model) - db.commit() - return - # TODO: Response model - - -@router.get("/permissions/search") -async def get_permissions(db: db_dependency, org_model: org_model_dependency, org_id: int, service_id: Optional[int] = None, resource: Optional[str] = None, action: Optional[str] = None): - # TODO: iam_admin_dependency - # TODO: request model - permission_query = db.query(Perm) - - if service_id is not None: - permission_query = permission_query.filter(Perm.service_id == service_id) - - if resource is not None: - permission_query = permission_query.filter(Perm.resource == resource) - - if action is not None: - permission_query = permission_query.filter(Perm.action == action) - - permission_models = permission_query.all() - - # TODO: Response model - return permission_models diff --git a/src/iam/schemas.py b/src/iam/schemas.py deleted file mode 100644 index a074c75..0000000 --- a/src/iam/schemas.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Pydantic models for - -Models: - - List: Description - - Models: Description -""" \ No newline at end of file diff --git a/src/iam/service.py b/src/iam/service.py deleted file mode 100644 index 1607cd0..0000000 --- a/src/iam/service.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Module specific business logic for - -Exports service_key_dependency -""" -from typing import Annotated - -from src.service.models import Service -from src.database import db_dependency -from src.schemas import ResourceName - -from fastapi import HTTPException, status, Request, Depends - - -def valid_service_key(db: db_dependency, request: Request, rn: ResourceName) -> bool: - api_key = request.headers.get("X-API-Key", None) - if not api_key: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - service = rn.service - result = db.query(Service).filter(Service.name == service).filter(Service.api_key == api_key).first() - if result is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - - return True - -service_key_dependency = Annotated[bool, Depends(valid_service_key)] diff --git a/src/iam/utils.py b/src/iam/utils.py deleted file mode 100644 index 5afbb54..0000000 --- a/src/iam/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Non-business logic reusable functions and classes for - -Classes: - - List: Description - - Classes: Description - -Functions: - - List: Description - - Functions: Description -""" diff --git a/src/organisation/dependencies.py b/src/organisation/dependencies.py index 26a7036..229ec62 100644 --- a/src/organisation/dependencies.py +++ b/src/organisation/dependencies.py @@ -8,21 +8,4 @@ Classes: Functions: - List: Description - Functions: Description -""" -from typing import Annotated - -from fastapi import HTTPException, Depends - -from src.database import db_dependency - -from src.organisation.models import Organisation as Org - - -def get_org_model(db: db_dependency, org_id: int) -> type[Org]: - org_model = db.query(Org).filter(Org.id == org_id).first() - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - - return org_model - -org_model_dependency = Annotated[type[Org], Depends(get_org_model)] +""" \ No newline at end of file diff --git a/src/organisation/models.py b/src/organisation/models.py index f696824..de2b17a 100644 --- a/src/organisation/models.py +++ b/src/organisation/models.py @@ -6,8 +6,7 @@ Models: billing_contact_id[fk], security_contact_id[fk], owner_contact_id[fk] - OrgUsers: org_id[fk][cpk], user_id[fk][cpk], is_admin """ -from sqlalchemy import Column, Integer, String, ForeignKey, JSON -from sqlalchemy.orm import relationship +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, JSON, false from src.database import Base @@ -16,36 +15,18 @@ class Organisation(Base): __tablename__ = "organisation" id = Column(Integer, primary_key=True) - name = Column(String, unique=True) + name = Column(String) status = Column(String, default="partial") intake_questionnaire = Column(JSON) - root_user_id = Column(Integer, ForeignKey("user.id")) - billing_contact_id = Column(Integer, ForeignKey("contact.id")) security_contact_id = Column(Integer, ForeignKey("contact.id")) owner_contact_id = Column(Integer, ForeignKey("contact.id")) - user_rel = relationship( - "User", - secondary="orgusers", - back_populates="organisation_rel" - ) - - group_rel = relationship("Group", back_populates="org_rel") - root_user_rel = relationship("User", foreign_keys=[root_user_id]) - - @property - def root_user_email(self): - return self.root_user_rel.email if self.root_user_rel else None - - billing_contact_rel = relationship("Contact", foreign_keys=[billing_contact_id]) - security_contact_rel = relationship("Contact", foreign_keys=[security_contact_id]) - owner_contact_rel = relationship("Contact", foreign_keys=[owner_contact_id]) - class OrgUsers(Base): __tablename__ = "orgusers" org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True) user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True) + is_admin = Column(Boolean, nullable=False, server_default=false()) diff --git a/src/organisation/router.py b/src/organisation/router.py index 1131c40..3313158 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -8,30 +8,27 @@ Endpoints: - [patch]/{org_id}/status - Updates the status of an organisation - [patch]/{org_id}/contact - Assigns a contact to an organisation (as billing, security, or owner) - [get]/{org_id}/users - Retrieves all users associated with an organisation + - [get]/{org_id}/users/admins - Retrieves only the admin users of an organisation - [post]/{org_id}/users - Adds a new user to an organisation + - [patch]/{org_id}/users - Updates details of an existing organisation user (e.g., admin status) - [delete]/{org_id} - Deletes an organisation by ID - [get]/{org_id}/contact/{contact_type} - Retrieves the contact of a specific type (owner, billing, security) for an organisation """ -from typing import Annotated, Optional +from typing import Annotated -from fastapi import APIRouter, HTTPException, status -from fastapi.params import Path, Query +from fastapi import APIRouter, HTTPException +from fastapi.params import Path + +from sqlalchemy.sql import exists -from src.contact.schemas import ContactAddress from src.database import db_dependency from src.contact.models import Contact -from src.user.models import User -from src.user.exceptions import UserNotFoundException -from src.auth.service import root_user_dependency, claims_dependency -from src.organisation.dependencies import org_model_dependency from src.organisation.constants import ContactType -from src.organisation.models import Organisation as Org +from src.organisation.models import Organisation as Org, OrgUsers from src.organisation.schemas import OrgOrgPostRequest, OrgQuestionnairePatchRequest, OrgStatusPatchRequest, \ OrgContactPatchRequest, \ - OrgUserPostRequest, OrgUserGetResponse, OrgContactGetResponse, OrgOrgGetResponse, OrgRootPatchRequest, \ - OrgGroupGetResponse, OrgUserDeleteRequest - + OrgUserPostRequest, OrgUserGetResponse, OrgContactGetResponse, OrgOrgGetResponse router = APIRouter( prefix="/org", @@ -40,165 +37,167 @@ router = APIRouter( @router.get("/id/{org_id}", response_model=OrgOrgGetResponse) -async def get_org_by_id(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): +async def get_org_by_id(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): + org_model = (db.query(Org).filter(Org.id == org_id).first()) + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") + response = { "name": org_model.name, "status": org_model.status, - "owner_contact": org_model.owner_contact_rel.email, - "billing_contact": org_model.billing_contact_rel.email, - "security_contact": org_model.security_contact_rel.email, - "root_user": org_model.root_user_email + "owner_contact": (db.query(Contact).filter(Contact.id == org_model.owner_contact_id).first()), + "billing_contact": (db.query(Contact).filter(Contact.id == org_model.billing_contact_id).first()), + "security_contact": (db.query(Contact).filter(Contact.id == org_model.security_contact_id).first()), } return response @router.post("/") -async def create_org(db: db_dependency, user: claims_dependency, org_request: OrgOrgPostRequest): - db_id: Optional[int] = user.get("db_id", None) - if db_id is None: - raise UserNotFoundException() - - org_model = Org(name=org_request.name, intake_questionnaire=org_request.intake_questionnaire.model_dump()) +async def create_org(db: db_dependency, org_request: OrgOrgPostRequest): + org_model = Org(**org_request.model_dump()) org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc db.add(org_model) - db.flush() - # Adds currently logged-in user to org users list and sets them as root_user - user_model = db.get(User, db_id) - org_model.user_rel.append(user_model) - org_model.root_user_rel = user_model - for contact_type in ["billing_contact_id", "security_contact_id", "owner_contact_id"]: - contact_model = Contact(org_id=org_model.id) - db.add(contact_model) - db.flush() - org_model.__setattr__(contact_type, contact_model.id) db.commit() @router.patch("/{org_id}/questionnaire") -async def update_questionnaire(db: db_dependency, org_model: org_model_dependency, q_request: OrgQuestionnairePatchRequest, org_id: Annotated[int, Path(gt=0)]): +async def update_questionnaire(db: db_dependency, q_request: OrgQuestionnairePatchRequest, org_id: Annotated[int, Path(gt=0)]): """ Route for updating 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_model.intake_questionnaire = q_request.intake_questionnaire.model_dump() + org_model = db.query(Org).filter(Org.id == org_id).first() + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") + + org_model.intake_questionnaire = q_request.intake_questionnaire # Allows for partially completed questionnaires to be saved without being submitted for review if not q_request.partial: org_model.status = "submitted" + db.add(org_model) db.commit() @router.patch("/{org_id}/status") -async def update_status(db: db_dependency, org_model: org_model_dependency, status_request: OrgStatusPatchRequest, org_id: Annotated[int, Path(gt=0)]): +async def update_status(db: db_dependency, status_request: OrgStatusPatchRequest, org_id: Annotated[int, Path(gt=0)]): + org_model = db.query(Org).filter(Org.id == org_id).first() + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") + org_model.status = status_request.status + db.add(org_model) db.commit() -@router.get("/{org_id}/users", response_model=OrgUserGetResponse) -async def get_users(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): - return {"users": [user.email for user in org_model.user_rel]} +@router.patch("/{org_id}/contact") +async def update_contact(db: db_dependency, contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]): + org_model = db.query(Org).filter(Org.id == org_id).first() + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") + + match contact_request.contact_type: + case "billing": + org_model.billing_contact_id = contact_request.contact_id + case "security": + org_model.security_contact_id = contact_request.contact_id + case "owner": + org_model.owner_contact_id = contact_request.contact_id + case _: + raise HTTPException(status_code=422, detail="Invalid contact type") + + db.add(org_model) + db.commit() + + +@router.get("/{org_id}/users", response_model=list[OrgUserGetResponse]) +async def get_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): + org_exists = db.query(exists().where(Org.id == org_id)).scalar() + if not org_exists: + raise HTTPException(status_code=404, detail="Organisation not found") + + org_user_models = db.query(OrgUsers).filter(OrgUsers.org_id == org_id).all() + + return org_user_models + + +@router.get("/{org_id}/users/admins", response_model=list[OrgUserGetResponse]) +async def get_admin_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): + org_exists = db.query(exists().where(Org.id == org_id)).scalar() + if not org_exists: + raise HTTPException(status_code=404, detail="Organisation not found") + + org_user_models = db.query(OrgUsers).filter(OrgUsers.org_id == org_id).filter(OrgUsers.is_admin == True).all() + + return org_user_models @router.post("/{org_id}/users") -async def add_user_to_org(db: db_dependency, org_model: org_model_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): - user_model = db.get(User, user_request.user_id) - if user_model in org_model.user_rel: - return - org_model.user_rel.append(user_model) +async def add_user_to_org(db: db_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): + org_model = (db.query(Org).filter(Org.id == org_id).first()) + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") + + org_user_model = OrgUsers(**user_request.model_dump(), org_id=org_id) + + db.add(org_user_model) db.commit() -@router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_organisation_by_id(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): +@router.patch("/{org_id}/users") +async def update_user_details(db: db_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): + """ + Currently used only to update user admin status for organisation. + """ + org_model = (db.query(Org).filter(Org.id == org_id).first()) + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") + + org_user_model = db.query(OrgUsers).filter(OrgUsers.org_id == org_id).filter(OrgUsers.user_id == user_request.user_id).first() + + if org_user_model is None: + raise HTTPException(status_code=404, detail="Organisation user not found") + + if user_request.is_admin is not None: + org_user_model.is_admin = user_request.is_admin + + db.add(org_user_model) + db.commit() + + +@router.delete("/{org_id}") +async def delete_organisation_by_id(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): + org_model = (db.query(Org).filter(Org.id == org_id).first()) + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") db.delete(org_model) db.commit() -@router.patch("/{org_id}/root_user", status_code=status.HTTP_204_NO_CONTENT) -async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_request: OrgRootPatchRequest): - root_user_model = db.get(User, user_request.user_id) - if root_user_model is None: - raise UserNotFoundException(user_id=user_request.user_id) +@router.get("/{org_id}/contact/{contact_type}", response_model=OrgContactGetResponse) +async def get_contact(db: db_dependency, contact_type: ContactType, org_id: Annotated[int, Path(gt=0)]): + org_model = db.query(Org).filter(Org.id == org_id).first() + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") - org_model.root_user_rel = root_user_model - db.commit() - - -@router.get("/{org_id}/groups", response_model=OrgGroupGetResponse) -async def get_org_groups(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): - return {"groups": [group.name for group in org_model.group_rel]} - - -@router.delete("/{org_id}/user", status_code=status.HTTP_204_NO_CONTENT) -async def remove_user_from_org(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_request: OrgUserDeleteRequest): - user_id = user_request.user_id - user = db.get(User, user_id) - - if user is None: - raise UserNotFoundException(user_id=user_id) - - if user not in org_model.user_rel: - raise HTTPException(status_code=status.HTTP_204_NOT_FOUND) - - org_model.user_rel.remove(user) - db.commit() - -@router.get("/{org_id}/contact", response_model=OrgContactGetResponse) -async def get_contact(org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], org_id: Annotated[int, Path(gt=0)]): match contact_type: case "billing": - contact_model = org_model.billing_contact_rel + contact_id = org_model.billing_contact_id case "security": - contact_model = org_model.security_contact_rel + contact_id = org_model.security_contact_id case "owner": - contact_model = org_model.owner_contact_rel + contact_id = org_model.owner_contact_id case _: raise HTTPException(status_code=422, detail="Invalid contact type") + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) if contact_model is None: raise HTTPException(status_code=404, detail="Contact not found") - return OrgContactGetResponse.model_construct( - **contact_model.__dict__, - address=ContactAddress.model_validate(contact_model) - ) - - - -@router.patch("/{org_id}/contact", response_model=OrgContactGetResponse) -async def update_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]): - match contact_type: - case "billing": - contact_model = org_model.billing_contact_rel - case "security": - contact_model = org_model.security_contact_rel - case "owner": - contact_model = org_model.owner_contact_rel - case _: - raise HTTPException(status_code=422, detail="Invalid contact type") - - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - update_data = contact_request.model_dump(exclude_none=True) - for key, value in update_data.items(): - if hasattr(contact_model, key): - setattr(contact_model, key, value) - else: - raise HTTPException(status_code=422, detail="Invalid keys in update request") - db.flush() - - response = OrgContactGetResponse.model_construct( - **contact_model.__dict__, - address=ContactAddress.model_validate(contact_model) - ) - - db.commit() - - return response + return contact_model diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index ae7322c..b4f5019 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -7,11 +7,9 @@ Models: """ from typing import Optional -from pydantic import EmailStr, ConfigDict - from src.schemas import CustomBaseModel from src.organisation.constants import Status, ContactType -from src.contact.schemas import ContactAddress + class OrgQuestionnaire(CustomBaseModel): question_one: str @@ -23,6 +21,10 @@ class OrgOrgPostRequest(CustomBaseModel): name: str intake_questionnaire: Optional[OrgQuestionnaire] = None + billing_contact_id: Optional[int] = None + security_contact_id: Optional[int] = None + owner_contact_id: Optional[int] = None + class OrgQuestionnairePatchRequest(CustomBaseModel): intake_questionnaire: OrgQuestionnaire partial: bool @@ -31,49 +33,27 @@ class OrgStatusPatchRequest(CustomBaseModel): status: Status class OrgContactPatchRequest(CustomBaseModel): - email: Optional[EmailStr] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - phonenumber: Optional[str] = None - vat_number: Optional[str] = None - post_office_box_number: Optional[str] = None - street_address: Optional[str] = None - street_address_line_2: Optional[str] = None - locality: Optional[str] = None - address_region: Optional[str] = None - country_code: Optional[str] = None - postal_code: Optional[str] = None + contact_id: int + contact_type: ContactType class OrgUserPostRequest(CustomBaseModel): user_id: int - -class OrgUserDeleteRequest(CustomBaseModel): - user_id: int - -class OrgRootPatchRequest(CustomBaseModel): - user_id: int + is_admin: Optional[bool] = False class OrgUserGetResponse(CustomBaseModel): - users: list[str] - -class OrgGroupGetResponse(CustomBaseModel): - groups: list[str] + user_id: int + is_admin: bool class OrgContactGetResponse(CustomBaseModel): - model_config = ConfigDict(from_attributes=True, extra="ignore") - - email: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - phonenumber: Optional[str] = None + email: str + first_name: str + last_name: str + phonenumber: str vat_number: Optional[str] = None - address: ContactAddress - class OrgOrgGetResponse(CustomBaseModel): name: str status: Status - root_user: Optional[str] = None - owner_contact: Optional[str] = None - billing_contact: Optional[str] = None - security_contact: Optional[str] = None + owner_contact: OrgContactGetResponse + billing_contact: OrgContactGetResponse + security_contact: OrgContactGetResponse diff --git a/src/schemas.py b/src/schemas.py index 52b0f94..e524f83 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -1,13 +1,5 @@ from pydantic import BaseModel -from typing import Optional class CustomBaseModel(BaseModel): pass - - -class ResourceName(CustomBaseModel): - service: str - organisation: str - resource: str - instance: Optional[str] = None diff --git a/src/service/config.py b/src/service/config.py deleted file mode 100644 index 4be170e..0000000 --- a/src/service/config.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Configurations for - -Configurations: - - List: Description - - Configs: Description -""" \ No newline at end of file diff --git a/src/service/constants.py b/src/service/constants.py deleted file mode 100644 index e1df957..0000000 --- a/src/service/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Constants and error codes for - -Constants: - - List: Description - - Consts: Description -""" \ No newline at end of file diff --git a/src/service/dependencies.py b/src/service/dependencies.py deleted file mode 100644 index 7447aaf..0000000 --- a/src/service/dependencies.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Router dependencies for - -Classes: - - List: Description - - Classes: Description - -Functions: - - List: Description - - Functions: Description -""" \ No newline at end of file diff --git a/src/service/exceptions.py b/src/service/exceptions.py deleted file mode 100644 index 5debbb4..0000000 --- a/src/service/exceptions.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Module specific exceptions for - -Exceptions: - - List: Description - - Exceptions: Description -""" \ No newline at end of file diff --git a/src/service/models.py b/src/service/models.py deleted file mode 100644 index 3f05e1d..0000000 --- a/src/service/models.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Database models for the services module - -Models: - - List: Description - - Models: Description -""" -from sqlalchemy import Column, Integer, String - -from src.database import Base - - -class Service(Base): - __tablename__ = "service" - - id = Column(Integer, primary_key=True) - name = Column(String, unique=True) - api_key = Column(String, unique=True) diff --git a/src/service/router.py b/src/service/router.py deleted file mode 100644 index 7617dc0..0000000 --- a/src/service/router.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Router endpoints for - -Endpoints: - - List: Description - - Endpoints: Description -""" -from fastapi import APIRouter - -from src.database import db_dependency - -from src.service.models import Service -from src.service.utils import generate_api_key - - -router = APIRouter( - tags=["Service"], - prefix="/service", -) - -@router.get("/") -async def get_all_services(db: db_dependency): - # TODO: user_dependency - # TODO: request model - permission_models = db.query(Service).all() - - # TODO: Response model - return permission_models - -@router.post("/") -async def register_service(db: db_dependency, service_name: str): - # TODO: super_admin_dependency - # TODO: request model - key = generate_api_key() - service_model = Service(name=service_name, api_key=key) - - db.add(service_model) - db.commit() - # TODO: response model - -@router.patch("/{service_id}/key") -async def regenerate_api_key(db: db_dependency, service_id: int): - # TODO: super_admin_dependency - # TODO: request model - key = generate_api_key() - service_model = db.query(Service).filter(Service.id==service_id).first() - service_model.api_key = key - - db.add(service_model) - db.commit() - # TODO: response model - -@router.delete("/{service_id}") -async def remove_service(db: db_dependency, service_id: int): - # TODO: super_admin_dependency - # TODO: request model - service_model = db.query(Service).filter(Service.id==service_id).first() - if service_model is None: - return - - db.delete(service_model) - db.commit() - # TODO: response model diff --git a/src/service/schemas.py b/src/service/schemas.py deleted file mode 100644 index a074c75..0000000 --- a/src/service/schemas.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Pydantic models for - -Models: - - List: Description - - Models: Description -""" \ No newline at end of file diff --git a/src/service/service.py b/src/service/service.py deleted file mode 100644 index 7365fa9..0000000 --- a/src/service/service.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Module specific business logic for - -Classes: - - List: Description - - Classes: Description - -Functions: - - List: Description - - Functions: Description -""" \ No newline at end of file diff --git a/src/service/utils.py b/src/service/utils.py deleted file mode 100644 index ecd41b0..0000000 --- a/src/service/utils.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Non-business logic reusable functions and classes for - -Classes: - - List: Description - - Classes: Description - -Functions: - - List: Description - - Functions: Description -""" -import uuid - - -def generate_api_key() -> str: - return str(uuid.uuid4()) diff --git a/src/user/models.py b/src/user/models.py index eac4684..89801d5 100644 --- a/src/user/models.py +++ b/src/user/models.py @@ -4,13 +4,9 @@ Database models for user module Models: - User - id[pk], email, first_name, last_name, oidc_id """ -from collections import defaultdict - from sqlalchemy import Column, Integer, String -from sqlalchemy.orm import relationship from src.database import Base -from src.iam.models import Group class User(Base): @@ -21,26 +17,3 @@ class User(Base): first_name = Column(String) last_name = Column(String) oidc_id = Column(String, index=True, unique=True) - - organisation_rel = relationship( - "Organisation", - secondary="orgusers", - back_populates="user_rel" - ) - - @property - def organisations(self): - return [org.name for org in self.organisation_rel] - - group_rel = relationship( - "Group", - secondary="user_groups", - back_populates="user_rel" - ) - - @property - def groups(self): - result = defaultdict(list) - for group in self.group_rel: - result[group.org_rel.name].append(group.name) - return dict(result) diff --git a/src/user/router.py b/src/user/router.py index 6d7a5a8..fb2b632 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -15,12 +15,15 @@ from typing import Annotated from fastapi import APIRouter from fastapi.params import Path +from sqlalchemy.sql import exists from starlette import status from src.user.models import User -from src.user.schemas import UserResponse, OIDCClaims +from src.user.schemas import UserResponse, OrgResponse, OIDCClaims from src.user.exceptions import UserNotFoundException +from src.organisation.models import OrgUsers, Organisation + from src.auth.service import claims_dependency from src.database import db_dependency @@ -60,6 +63,55 @@ async def current_user(user: claims_dependency, db: db_dependency): return user_model +@router.get("/self/orgs", response_model=list[OrgResponse], status_code=status.HTTP_200_OK, responses={ + status.HTTP_404_NOT_FOUND: {"description": "User not found"}, + status.HTTP_200_OK: {"description": "Successful retrieval from database"}, +}) +async def get_current_organisations(db: db_dependency, user: claims_dependency): + """ + Returns all organisations associated with the currently logged-in user. + """ + user_id = user.get("db_id", None) + if user_id is None: + raise UserNotFoundException() + user_exists = db.query(exists().where(User.id == user_id)).scalar() + if not user_exists: + raise UserNotFoundException(user_id=user_id) + + org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) + .join(OrgUsers, Organisation.id == OrgUsers.org_id) + .filter(OrgUsers.user_id == user_id) + .all() + ) + + return org_user_models + + +@router.get("/self/orgs/admin", response_model=list[OrgResponse], status_code=status.HTTP_200_OK, responses={ + status.HTTP_404_NOT_FOUND: {"description": "User not found"}, + status.HTTP_200_OK: {"description": "Successful retrieval from database"}, +}) +async def get_current_admin_organisations(db: db_dependency, user: claims_dependency): + """ + Returns the organisations for which the currently logged-in user is an admin. + """ + user_id = user.get("db_id", None) + if user_id is None: + raise UserNotFoundException() + user_exists = db.query(exists().where(User.id == user_id)).scalar() + if not user_exists: + raise UserNotFoundException(user_id=user_id) + + org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) + .join(OrgUsers, Organisation.id == OrgUsers.org_id) + .filter(OrgUsers.user_id == user_id) + .filter(OrgUsers.is_admin == True) + .all() + ) + + return org_user_models + + @router.get("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK, responses={ status.HTTP_404_NOT_FOUND: {"description": "User not found"}, status.HTTP_200_OK: {"description": "Successful retrieval from database"}, @@ -75,6 +127,49 @@ async def get_user_by_id(db: db_dependency, user_id: Annotated[int, Path(gt=0,de return user_model +@router.get("/{user_id}/orgs", response_model=list[OrgResponse], status_code=status.HTTP_200_OK, responses={ + status.HTTP_404_NOT_FOUND: {"description": "User not found"}, + status.HTTP_200_OK: {"description": "Successful retrieval from database"}, +}) +async def get_organisations(db: db_dependency, user_id: Annotated[int, Path(gt=0,description="User database ID")]): + """ + Returns all organisations associated with the provided user ID. + """ + user_exists = db.query(exists().where(User.id == user_id)).scalar() + if not user_exists: + raise UserNotFoundException(user_id=user_id) + + org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) + .join(OrgUsers, Organisation.id == OrgUsers.org_id) + .filter(OrgUsers.user_id == user_id) + .all() + ) + + return org_user_models + + +@router.get("/{user_id}/orgs/admin", response_model=list[OrgResponse], status_code=status.HTTP_200_OK, responses={ + status.HTTP_404_NOT_FOUND: {"description": "User not found"}, + status.HTTP_200_OK: {"description": "Successful retrieval from database"}, +}) +async def get_admin_organisations(db: db_dependency, user_id: Annotated[int, Path(gt=0,description="User database ID")]): + """ + Returns the organisations for which the user with the provided user ID is an admin. + """ + user_exists = db.query(exists().where(User.id == user_id)).scalar() + if not user_exists: + raise UserNotFoundException(user_id=user_id) + + org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) + .join(OrgUsers, Organisation.id == OrgUsers.org_id) + .filter(OrgUsers.user_id == user_id) + .filter(OrgUsers.is_admin == True) + .all() + ) + + return org_user_models + + @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, responses={ status.HTTP_204_NO_CONTENT: {"description": "User deleted"}, status.HTTP_404_NOT_FOUND: {"description": "User not found"}, diff --git a/src/user/schemas.py b/src/user/schemas.py index 7e3dc12..2dd29ab 100644 --- a/src/user/schemas.py +++ b/src/user/schemas.py @@ -5,7 +5,6 @@ Models: - List: Description - Models: Description """ -from typing import Optional from src.schemas import CustomBaseModel @@ -45,10 +44,9 @@ class UserResponse(CustomBaseModel): first_name: str last_name: str email: str - organisations: list[Optional[str]] - groups: dict[str, list[str]] class OrgResponse(CustomBaseModel): org_id: int name: str + is_admin: bool