diff --git a/.alembic/env.py b/.alembic/env.py index ec4697c..87dfd54 100644 --- a/.alembic/env.py +++ b/.alembic/env.py @@ -10,6 +10,8 @@ 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 new file mode 100644 index 0000000..3197d47 --- /dev/null +++ b/.alembic/versions/2026-05-22_init_iam.py @@ -0,0 +1,83 @@ +"""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/src/admin/router.py b/src/admin/router.py index e6df7de..13ac897 100644 --- a/src/admin/router.py +++ b/src/admin/router.py @@ -5,43 +5,9 @@ Endpoints: - List: Description - Endpoints: Description """ -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 - +from fastapi import APIRouter 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 502ed47..bf53dd0 100644 --- a/src/api.py +++ b/src/api.py @@ -8,6 +8,8 @@ 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() @@ -17,6 +19,8 @@ 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 5a8636f..5e8871d 100644 --- a/src/auth/router.py +++ b/src/auth/router.py @@ -8,54 +8,4 @@ from fastapi import APIRouter router = APIRouter( tags=["auth"], -) - - - - -# 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='/') +) \ No newline at end of file diff --git a/src/auth/service.py b/src/auth/service.py index fc3cd4c..40a696b 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -88,33 +88,6 @@ 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_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") - - exists_query = (db.query(OrgUsers) - .filter(OrgUsers.org_id == org_id, - OrgUsers.user_id == db_id, - OrgUsers.is_admin == True - ).exists() - ) - - 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 - - -org_admin_dependency = Annotated[dict[str, Any], Depends(is_org_admin)] - - async def is_super_admin(claims: claims_dependency): super_admin_ids = [] @@ -128,114 +101,3 @@ 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/iam/config.py b/src/iam/config.py new file mode 100644 index 0000000..4be170e --- /dev/null +++ b/src/iam/config.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000..e1df957 --- /dev/null +++ b/src/iam/constants.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000..7447aaf --- /dev/null +++ b/src/iam/dependencies.py @@ -0,0 +1,11 @@ +""" +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 new file mode 100644 index 0000000..5debbb4 --- /dev/null +++ b/src/iam/exceptions.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000..5ec6065 --- /dev/null +++ b/src/iam/models.py @@ -0,0 +1,43 @@ +""" +Database models for the IAM module + +Models: + - List: Description + - Models: Description +""" +from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint + +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")) + + +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 new file mode 100644 index 0000000..17e6673 --- /dev/null +++ b/src/iam/router.py @@ -0,0 +1,192 @@ +""" +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 new file mode 100644 index 0000000..a074c75 --- /dev/null +++ b/src/iam/schemas.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000..1607cd0 --- /dev/null +++ b/src/iam/service.py @@ -0,0 +1,26 @@ +""" +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 new file mode 100644 index 0000000..5afbb54 --- /dev/null +++ b/src/iam/utils.py @@ -0,0 +1,11 @@ +""" +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 229ec62..26a7036 100644 --- a/src/organisation/dependencies.py +++ b/src/organisation/dependencies.py @@ -8,4 +8,21 @@ Classes: Functions: - List: Description - Functions: Description -""" \ No newline at end of file +""" +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)] diff --git a/src/organisation/models.py b/src/organisation/models.py index de2b17a..7ed5d91 100644 --- a/src/organisation/models.py +++ b/src/organisation/models.py @@ -6,7 +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, Boolean, ForeignKey, JSON, false +from sqlalchemy import Column, Integer, String, ForeignKey, JSON from src.database import Base @@ -15,10 +15,12 @@ class Organisation(Base): __tablename__ = "organisation" id = Column(Integer, primary_key=True) - name = Column(String) + name = Column(String, unique=True) 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")) @@ -29,4 +31,3 @@ class OrgUsers(Base): 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 3313158..e9af47d 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -8,22 +8,22 @@ 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 -from fastapi import APIRouter, HTTPException -from fastapi.params import Path +from fastapi import APIRouter, HTTPException, status +from fastapi.params import Path, Query from sqlalchemy.sql import exists from src.database import db_dependency from src.contact.models import Contact +from src.iam.models import Group +from src.organisation.dependencies import org_model_dependency from src.organisation.constants import ContactType from src.organisation.models import Organisation as Org, OrgUsers from src.organisation.schemas import OrgOrgPostRequest, OrgQuestionnairePatchRequest, OrgStatusPatchRequest, \ @@ -127,17 +127,6 @@ async def get_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): 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, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): org_model = (db.query(Org).filter(Org.id == org_id).first()) @@ -150,27 +139,6 @@ async def add_user_to_org(db: db_dependency, user_request: OrgUserPostRequest, o db.commit() -@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()) @@ -201,3 +169,39 @@ async def get_contact(db: db_dependency, contact_type: ContactType, org_id: Anno raise HTTPException(status_code=404, detail="Contact not found") return contact_model + + +@router.get("/{org_id}/root_user") +async def get_org_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): + root_user = org_model.root_user_id + + return {"root_user": root_user} + + +@router.patch("/{org_id}/root_user") +async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], root_user: Annotated[int, Query(gt=0)]): + # TODO: Request model, ditch query + # TODO: Verify root_user exists, possibly with a user_model_dependency + org_model.root_user_id = root_user + db.add(org_model) + db.commit() + # TODO: Response model + + +@router.get("/{org_id}/groups") +async def get_org_groups(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): + org_group_models = db.query(Group).filter(Group.org_id == org_id).all() + + # TODO: Response model + return org_group_models + +@router.delete("/{org_id}/user") +async def remove_user_from_org(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_id: Annotated[int, Query(gt=0)]): + orguser_model = db.query(OrgUsers).filter(OrgUsers.org_id == org_id, OrgUsers.user_id == user_id).first() + + if orguser_model is None: + raise HTTPException(status_code=status.HTTP_204_NO_CONTENT) + + db.delete(orguser_model) + db.commit() + pass diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index b4f5019..3801195 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -38,11 +38,9 @@ class OrgContactPatchRequest(CustomBaseModel): class OrgUserPostRequest(CustomBaseModel): user_id: int - is_admin: Optional[bool] = False class OrgUserGetResponse(CustomBaseModel): user_id: int - is_admin: bool class OrgContactGetResponse(CustomBaseModel): email: str diff --git a/src/schemas.py b/src/schemas.py index e524f83..52b0f94 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -1,5 +1,13 @@ 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 new file mode 100644 index 0000000..4be170e --- /dev/null +++ b/src/service/config.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000..e1df957 --- /dev/null +++ b/src/service/constants.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000..7447aaf --- /dev/null +++ b/src/service/dependencies.py @@ -0,0 +1,11 @@ +""" +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 new file mode 100644 index 0000000..5debbb4 --- /dev/null +++ b/src/service/exceptions.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000..3f05e1d --- /dev/null +++ b/src/service/models.py @@ -0,0 +1,18 @@ +""" +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 new file mode 100644 index 0000000..7617dc0 --- /dev/null +++ b/src/service/router.py @@ -0,0 +1,63 @@ +""" +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 new file mode 100644 index 0000000..a074c75 --- /dev/null +++ b/src/service/schemas.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 0000000..7365fa9 --- /dev/null +++ b/src/service/service.py @@ -0,0 +1,11 @@ +""" +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 new file mode 100644 index 0000000..ecd41b0 --- /dev/null +++ b/src/service/utils.py @@ -0,0 +1,16 @@ +""" +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/router.py b/src/user/router.py index fb2b632..90e63ac 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -23,7 +23,7 @@ from src.user.schemas import UserResponse, OrgResponse, OIDCClaims from src.user.exceptions import UserNotFoundException from src.organisation.models import OrgUsers, Organisation - +from src.iam.models import Group, UserGroups from src.auth.service import claims_dependency from src.database import db_dependency @@ -78,7 +78,7 @@ async def get_current_organisations(db: db_dependency, user: claims_dependency): if not user_exists: raise UserNotFoundException(user_id=user_id) - org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) + org_user_models = (db.query(OrgUsers.org_id, Organisation.name) .join(OrgUsers, Organisation.id == OrgUsers.org_id) .filter(OrgUsers.user_id == user_id) .all() @@ -87,31 +87,6 @@ async def get_current_organisations(db: db_dependency, user: claims_dependency): 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"}, @@ -139,7 +114,7 @@ async def get_organisations(db: db_dependency, user_id: Annotated[int, Path(gt=0 if not user_exists: raise UserNotFoundException(user_id=user_id) - org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) + org_user_models = (db.query(OrgUsers.org_id, Organisation.name) .join(OrgUsers, Organisation.id == OrgUsers.org_id) .filter(OrgUsers.user_id == user_id) .all() @@ -148,28 +123,6 @@ async def get_organisations(db: db_dependency, user_id: Annotated[int, Path(gt=0 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"}, @@ -183,3 +136,15 @@ async def delete_user_by_id(user_id: Annotated[int, Path(gt=0)], db: db_dependen raise UserNotFoundException(user_id=user_id) db.delete(user_model) db.commit() + + +@router.get("/{user_id}/groups") +async def get_user_groups(db: db_dependency, user_id: Annotated[int, Path(gt=0,description="User database ID")]): + user_model = (db.query(User).filter(User.id == user_id).first()) + if user_model is None: + raise UserNotFoundException(user_id=user_id) + + user_groups = db.query(Group).join(UserGroups).filter(UserGroups.user_id==user_id).all() + + # TODO: Response model + return user_groups diff --git a/src/user/schemas.py b/src/user/schemas.py index 2dd29ab..f33ae54 100644 --- a/src/user/schemas.py +++ b/src/user/schemas.py @@ -49,4 +49,3 @@ class UserResponse(CustomBaseModel): class OrgResponse(CustomBaseModel): org_id: int name: str - is_admin: bool