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
def service_name(self):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,9 +6,36 @@ Models follow the nomenclature of:
- 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):
@ -33,3 +60,12 @@ class ServicePatchKeyRequest(ServiceIDMixin):
class ServicePatchKeyResponse(CustomBaseModel):
service: ServiceWithKeySchema
class ServicePostPermissionsRequest(CustomBaseModel):
rn: ServiceNameMixin
permissions: list[PermissionRequestSchema]
class ServicePostPermissionsResponse(CustomBaseModel):
permissions: list[tuple[PermissionResponseSchema, int]]