feat: iam rbac system

Endpoints and db architecture to support a role based IAM system.
This commit is contained in:
Chris Milne 2026-05-25 09:05:17 +01:00
parent 7b3ee9d5fa
commit 23f2ce98d7
31 changed files with 634 additions and 317 deletions

View file

@ -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

View file

@ -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)

View file

@ -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='/')
)

View file

@ -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"])

7
src/iam/config.py Normal file
View file

@ -0,0 +1,7 @@
"""
Configurations for <this module>
Configurations:
- List: Description
- Configs: Description
"""

7
src/iam/constants.py Normal file
View file

@ -0,0 +1,7 @@
"""
Constants and error codes for <this module>
Constants:
- List: Description
- Consts: Description
"""

11
src/iam/dependencies.py Normal file
View file

@ -0,0 +1,11 @@
"""
Router dependencies for <this module>
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

7
src/iam/exceptions.py Normal file
View file

@ -0,0 +1,7 @@
"""
Module specific exceptions for <this module>
Exceptions:
- List: Description
- Exceptions: Description
"""

43
src/iam/models.py Normal file
View file

@ -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)

192
src/iam/router.py Normal file
View file

@ -0,0 +1,192 @@
"""
Router endpoints for <this module>
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

7
src/iam/schemas.py Normal file
View file

@ -0,0 +1,7 @@
"""
Pydantic models for <this module>
Models:
- List: Description
- Models: Description
"""

26
src/iam/service.py Normal file
View file

@ -0,0 +1,26 @@
"""
Module specific business logic for <this module>
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)]

11
src/iam/utils.py Normal file
View file

@ -0,0 +1,11 @@
"""
Non-business logic reusable functions and classes for <this module>
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

View file

@ -8,4 +8,21 @@ 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)]

View file

@ -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())

View file

@ -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

View file

@ -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

View file

@ -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

7
src/service/config.py Normal file
View file

@ -0,0 +1,7 @@
"""
Configurations for <this module>
Configurations:
- List: Description
- Configs: Description
"""

7
src/service/constants.py Normal file
View file

@ -0,0 +1,7 @@
"""
Constants and error codes for <this module>
Constants:
- List: Description
- Consts: Description
"""

View file

@ -0,0 +1,11 @@
"""
Router dependencies for <this module>
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

View file

@ -0,0 +1,7 @@
"""
Module specific exceptions for <this module>
Exceptions:
- List: Description
- Exceptions: Description
"""

18
src/service/models.py Normal file
View file

@ -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)

63
src/service/router.py Normal file
View file

@ -0,0 +1,63 @@
"""
Router endpoints for <this module>
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

7
src/service/schemas.py Normal file
View file

@ -0,0 +1,7 @@
"""
Pydantic models for <this module>
Models:
- List: Description
- Models: Description
"""

11
src/service/service.py Normal file
View file

@ -0,0 +1,11 @@
"""
Module specific business logic for <this module>
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

16
src/service/utils.py Normal file
View file

@ -0,0 +1,16 @@
"""
Non-business logic reusable functions and classes for <this module>
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""
import uuid
def generate_api_key() -> str:
return str(uuid.uuid4())

View file

@ -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

View file

@ -49,4 +49,3 @@ class UserResponse(CustomBaseModel):
class OrgResponse(CustomBaseModel):
org_id: int
name: str
is_admin: bool