Compare commits

...

5 commits

Author SHA1 Message Date
d6c14655c0 feat: batch add perm to org
All checks were successful
ci / lint_and_test (push) Successful in 16s
2026-06-16 16:48:32 +01:00
4b384db98a feat: service permissions endpoint
Endpoint to allow services to register their own permissions into the hub.
2026-06-16 16:24:09 +01:00
327f857190 feat: service-permission orm relationship 2026-06-16 16:10:08 +01:00
154870acb1 feat: service key dependency generic
Dependency to verify service API key accepts the service_name from a RN generic, allowing for endpoints without a full RN to use it.
2026-06-16 16:09:17 +01:00
f96cb2112c minor: rename search endpoint function 2026-06-16 16:05:17 +01:00
8 changed files with 172 additions and 17 deletions

View file

@ -42,7 +42,9 @@ class Permission(Base):
), ),
) )
service_rel = relationship("Service", foreign_keys="Permission.service_id") service_rel = relationship(
"Service", back_populates="permission_rel", foreign_keys="Permission.service_id"
)
@property @property
def service_name(self): def service_name(self):

View file

@ -23,6 +23,7 @@ from sqlalchemy.exc import IntegrityError
from psycopg.errors import UniqueViolation from psycopg.errors import UniqueViolation
from src.iam.exceptions import GroupNotFoundException from src.iam.exceptions import GroupNotFoundException
from src.organisation.dependencies import org_model_body_dependency
from src.organisation.exceptions import OrgNotFoundException from src.organisation.exceptions import OrgNotFoundException
from src.schemas import GroupSummary, OrgSummary, ResourceName from src.schemas import GroupSummary, OrgSummary, ResourceName
from src.service.dependencies import service_model_body_dependency from src.service.dependencies import service_model_body_dependency
@ -82,6 +83,8 @@ from src.iam.schemas import (
IAMCAoRResponse, IAMCAoRResponse,
IAMPutGroupInvitationAcceptResponse, IAMPutGroupInvitationAcceptResponse,
IAMPutGroupInvitationResponse, IAMPutGroupInvitationResponse,
IAMPutOrgPermissionsRequest,
IAMPutOrgPermissionsResponse,
) )
from src.utils import verify_email_token from src.utils import verify_email_token
@ -547,7 +550,7 @@ async def delete_permission(
response_model=IAMGetPermissionsSearchResponse, response_model=IAMGetPermissionsSearchResponse,
responses={}, responses={},
) )
async def post_permissions( async def permissions_search(
db: db_dependency, db: db_dependency,
org_model: org_model_root_claim_body_dependency, org_model: org_model_root_claim_body_dependency,
request_model: IAMGetPermissionsSearchRequest, request_model: IAMGetPermissionsSearchRequest,
@ -672,3 +675,36 @@ async def accept_invitation(
db.commit() db.commit()
return response return response
@router.put(
path="/org/permissions",
summary="Grants an org access to permissions",
status_code=status.HTTP_200_OK,
response_model=IAMPutOrgPermissionsResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {"description": "Must be super user."},
},
)
async def add_org_permissions(
db: db_dependency,
su: super_admin_dependency,
org_model: org_model_body_dependency,
request_model: IAMPutOrgPermissionsRequest,
):
"""
Grants a permission to a group. Returns a list of the permissions in the group as well as a summary for the org and group.
"""
for permission in request_model.permissions:
perm_model = db.get(Perm, permission)
if perm_model not in org_model.permission_rel:
org_model.permission_rel.append(perm_model)
db.flush()
response = IAMPutOrgPermissionsResponse(
organisation=OrgSummary(**org_model.__dict__),
permissions=org_model.permission_rel,
)
db.commit()
return response

View file

@ -150,3 +150,12 @@ class IAMPutGroupInvitationAcceptResponse(CustomBaseModel):
organisation: OrgSummary organisation: OrgSummary
user: UserSummary user: UserSummary
group: GroupDetails group: GroupDetails
class IAMPutOrgPermissionsRequest(OrgIDMixin):
permissions: list[int]
class IAMPutOrgPermissionsResponse(CustomBaseModel):
organisation: OrgSummary
permissions: list[PermissionSchema]

View file

@ -7,21 +7,19 @@ Exports:
from typing import Annotated from typing import Annotated
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import Request, Depends
from src.service.models import Service
from src.database import db_dependency from src.database import db_dependency
from src.exceptions import UnauthorizedException from src.exceptions import UnauthorizedException
from src.utils import send_email, generate_jwt from src.utils import send_email, generate_jwt
from src.iam.schemas import IAMCAoRRequest
from src.iam.models import Group from src.iam.models import Group
from src.service.models import Service
from fastapi import Request, Depends from src.service.schemas import HasServiceName
def valid_service_key( def valid_service_key(
db: db_dependency, request: Request, request_model: IAMCAoRRequest db: db_dependency, request: Request, request_model: HasServiceName
) -> bool: ) -> bool:
rn = request_model.rn rn = request_model.rn
api_key = request.headers.get("X-API-Key", None) api_key = request.headers.get("X-API-Key", None)

View file

@ -14,13 +14,6 @@ class CustomBaseModel(BaseModel):
pass pass
class ResourceName(CustomBaseModel):
service: str
organisation: str
resource: str
instance: Optional[str] = None
### Mixins ### ### Mixins ###
class OrgIDMixin(CustomBaseModel): class OrgIDMixin(CustomBaseModel):
organisation_id: int = Field(gt=0) organisation_id: int = Field(gt=0)
@ -42,6 +35,10 @@ class UserIDMixin(CustomBaseModel):
user_id: int = Field(gt=0) user_id: int = Field(gt=0)
class ServiceNameMixin(CustomBaseModel):
service: str
class OrgSummary(CustomBaseModel): class OrgSummary(CustomBaseModel):
id: int id: int
name: str name: str
@ -60,3 +57,9 @@ class UserSummary(CustomBaseModel):
class ServiceSummary(CustomBaseModel): class ServiceSummary(CustomBaseModel):
id: int id: int
name: str name: str
class ResourceName(ServiceNameMixin):
organisation: str
resource: str
instance: Optional[str] = None

View file

@ -7,6 +7,7 @@ Models:
""" """
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from src.database import Base from src.database import Base
@ -17,3 +18,5 @@ class Service(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
name = Column(String, unique=True) name = Column(String, unique=True)
api_key = Column(String, unique=True) api_key = Column(String, unique=True)
permission_rel = relationship("Permission", back_populates="service_rel")

View file

@ -18,6 +18,9 @@ from src.auth.dependencies import (
super_admin_dependency, super_admin_dependency,
org_model_root_claim_query_dependency, org_model_root_claim_query_dependency,
) )
from src.iam.service import service_key_dependency
from src.iam.models import Permission as Perm
from src.service.exceptions import ServiceNotFoundException
from src.service.models import Service from src.service.models import Service
from src.service.utils import generate_api_key from src.service.utils import generate_api_key
@ -32,6 +35,8 @@ from src.service.schemas import (
ServiceWithKeySchema, ServiceWithKeySchema,
ServicePatchKeyResponse, ServicePatchKeyResponse,
ServicePatchKeyRequest, ServicePatchKeyRequest,
ServicePostPermissionsResponse,
ServicePostPermissionsRequest,
) )
router = APIRouter( router = APIRouter(
@ -170,3 +175,66 @@ async def remove_service(
""" """
db.delete(service_model) db.delete(service_model)
db.commit() db.commit()
@router.post(
path="/permissions",
summary="Service endpoint for creating its own permissions.",
status_code=status.HTTP_200_OK,
response_model=ServicePostPermissionsResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": "API Key missing or invalid | Issue verifying user OIDC claims"
},
},
)
async def service_create_new_permissions(
db: db_dependency,
request_model: ServicePostPermissionsRequest,
valid_key: service_key_dependency,
):
"""
Allows a service to register its own set of permissions.
"""
service_model = (
db.query(Service).filter(Service.name == request_model.rn.service).first()
)
if service_model is None:
raise ServiceNotFoundException()
else:
service_id = service_model.id
response_list = []
for new_permission in request_model.permissions:
perm_model = (
db.query(Perm)
.filter(Perm.service_id == service_id)
.filter(Perm.resource == new_permission.resource)
.filter(Perm.action == new_permission.action)
.first()
)
if perm_model is not None:
response_code = 409
response = {
"id": perm_model.id,
"service_name": perm_model.service_name,
"resource": perm_model.resource,
"action": perm_model.action,
}
response_list.append((response, response_code))
continue
new_perm_model = Perm(**new_permission.__dict__)
new_perm_model.service_id = service_id
db.add(new_perm_model)
db.flush()
response_code = 201
response = {
"id": new_perm_model.id,
"service_name": new_perm_model.service_name,
"resource": new_perm_model.resource,
"action": new_perm_model.action,
}
response_list.append((response, response_code))
db.commit()
return {"permissions": response_list}

View file

@ -6,9 +6,36 @@ Models follow the nomenclature of:
- Models: "<Module><Method><Resource><Opt:Resource><Direction>" ie "ServiceGetServiceResponse" - Models: "<Module><Method><Resource><Opt:Resource><Direction>" ie "ServiceGetServiceResponse"
""" """
from pydantic import Field from typing import Generic, TypeVar
from pydantic import Field, ConfigDict
from src.schemas import CustomBaseModel, ServiceIDMixin, ServiceSummary from src.schemas import (
CustomBaseModel,
ServiceIDMixin,
ServiceSummary,
ServiceNameMixin,
)
T = TypeVar("T", bound=ServiceNameMixin)
class HasServiceName(CustomBaseModel, Generic[T]):
rn: T
class PermissionResponseSchema(CustomBaseModel):
model_config = ConfigDict(from_attributes=True, extra="ignore")
id: int
service_name: str
resource: str
action: str
class PermissionRequestSchema(CustomBaseModel):
resource: str
action: str
class ServiceWithKeySchema(ServiceSummary): class ServiceWithKeySchema(ServiceSummary):
@ -33,3 +60,12 @@ class ServicePatchKeyRequest(ServiceIDMixin):
class ServicePatchKeyResponse(CustomBaseModel): class ServicePatchKeyResponse(CustomBaseModel):
service: ServiceWithKeySchema service: ServiceWithKeySchema
class ServicePostPermissionsRequest(CustomBaseModel):
rn: ServiceNameMixin
permissions: list[PermissionRequestSchema]
class ServicePostPermissionsResponse(CustomBaseModel):
permissions: list[tuple[PermissionResponseSchema, int]]