1
0
Fork 0
forked from sr2/cloud-api
cloud-api/src/iam/router.py

649 lines
18 KiB
Python

"""
Router endpoints for IAM
Endpoints:
- [POST](/api/v1/iam/can_act_on_resource): [API Key & User JWT]: Used for services to check user access permission
- [GET](/api/v1/iam/group/permissions): [Root User]: Gets a list of permissions granted to a group
- [GET](/api/v1/iam/group/users): [Root User]: Gets a list of users assigned to a group
- [POST](/api/v1/iam/group): [Root User]: Creates a new group
- [PUT](/api/v1/iam/group/permission): [Root User]: Grants a permission to a group
- [PUT](/api/v1/iam/group/user): [Root User]: Directly adds a user to the group
- [DELETE](/api/v1/iam/group/permissions): [Root User]: Removes a permission from the group
- [DELETE](/api/v1/iam/group/user): [Root User]: Removes a user from the group
- [GET](/api/v1/iam/permissions): [Root User]: Returns a full list of permissions
- [POST](/api/v1/iam/permission): [Super Admin]: Creates a new permission for a service
- [DELETE](/api/v1/iam/permission): [Super Admin]: Deletes a permission
- [POST](/api/v1/iam/permissions/search): [Root User]: Search list of permissions
- [PUT](/api/v1/iam/group/user/invitation): [Root User]: Send an email invitation for non-org member to join a group
- [PUT](/api/v1/iam/group/user//invitation/accept): [User Claim & Invite JWT]: Accept email invitation to join an org's group
"""
from fastapi import APIRouter, status, BackgroundTasks
from sqlalchemy.exc import IntegrityError
from src.iam.exceptions import GroupNotFoundException
from src.organisation.exceptions import OrgNotFoundException
from src.schemas import GroupSummary, OrgSummary, ResourceName
from src.service.exceptions import ServiceNotFoundException
from src.exceptions import (
ConflictException,
ForbiddenException,
UnprocessableContentException,
)
from src.database import db_dependency
from src.auth.service import claims_dependency
from src.auth.dependencies import (
org_model_root_claim_query_dependency,
org_model_root_claim_body_dependency,
super_admin_dependency,
)
from src.user.models import User
from src.user.dependencies import (
user_model_body_dependency,
user_model_query_dependency,
user_model_claims_dependency,
)
from src.organisation.models import Organisation as Org
from src.service.models import Service
from src.iam.service import service_key_dependency, send_user_group_invitation
from src.iam.models import (
Permission as Perm,
GroupPermissions as GPerms,
Group,
UserGroups,
)
from src.iam.dependencies import (
group_model_query_dependency,
group_model_body_dependency,
perm_model_body_dependency,
perm_model_query_dependency,
)
from src.iam.schemas import (
GroupSchema,
IAMCAoRRequest,
IAMGetGroupPermissionsResponse,
IAMGetGroupUsersResponse,
IAMPostGroupRequest,
IAMPostGroupResponse,
IAMPutGroupPermissionRequest,
IAMPutGroupPermissionResponse,
IAMPutGroupUserRequest,
IAMPutGroupUserResponse,
IAMDeleteGroupPermissionResponse,
IAMDeleteGroupUserResponse,
IAMGetPermissionsResponse,
IAMPostPermissionRequest,
IAMPostPermissionResponse,
IAMGetPermissionsSearchRequest,
IAMGetPermissionsSearchResponse,
IAMPutGroupInvitationRequest,
IAMPutGroupInvitationAcceptRequest,
IAMCAoRResponse,
)
from src.utils import verify_email_token
router = APIRouter(
tags=["IAM"],
prefix="/iam",
)
@router.post(
path="/can_act_on_resource",
summary="Used for services to check user access permission",
status_code=status.HTTP_200_OK,
response_model=IAMCAoRResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": "API Key missing or invalid | Issue verifying user OIDC claims"
},
},
)
async def can_act_on_resource(
valid_key: service_key_dependency,
db: db_dependency,
user_claims: claims_dependency,
request_model: IAMCAoRRequest,
):
"""
This endpoint is not meant for the Hub frontend to interact with.\n
Services accessing this endpoint must be already registered within the Hub and been issued an API key.\n
Resource Names have an instance property but permissions do not presently have that level of granularity.\n
"""
response = {
"allowed": False,
"rn": ResourceName(organisation="", service="", resource=""),
"action": "",
"user": {"id": 0, "email": ""},
}
try:
rn = request_model.rn
action = request_model.action
user_id = user_claims["db_id"]
rn_org = rn.organisation
rn_service = rn.service
rn_resource = rn.resource
response["user"] = {"id": user_id, "email": user_claims["email"]}
response["action"] = action
response["rn"] = rn
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:
response["allowed"] = True
else:
response["allowed"] = False
except Exception:
response["allowed"] = False
return response
@router.get(
path="/group/permissions",
summary="Gets a list of permissions granted to a group",
status_code=status.HTTP_200_OK,
response_model=IAMGetGroupPermissionsResponse,
responses={
status.HTTP_422_UNPROCESSABLE_CONTENT: {
"description": "Unprocessable content.",
"content": {
"application/json": {
"examples": {
"org_id": {"summary": "Invalid or missing org ID."},
"oidc_claims": {"summary": "Invalid or missing OIDC claims."},
}
}
},
},
status.HTTP_401_UNAUTHORIZED: {
"description": "Unauthorized",
"content": {
"application/json": {
"examples": {
"awaiting_approval": {
"summary": "Organisation has not yet been approved."
},
"expired_token": {"summary": "User token has expired."},
"oidc": {"summary": "Failed to verify OIDC claims."},
}
}
},
},
status.HTTP_403_FORBIDDEN: {
"description": "Forbidden",
"content": {
"application/json": {
"examples": {
"not_root": {"summary": "Not authorised. Must be root user."},
}
}
},
},
status.HTTP_404_NOT_FOUND: {
"description": "Not found",
"content": {
"application/json": {
"examples": {
"db_id": {
"summary": "User not found in db when checking claims."
},
"user_model": {"summary": "User model not found in db."},
"org_model": {"summary": "Org model not found in db."},
"group_model": {"summary": "Group model not found in db."},
}
}
},
},
},
)
async def get_group_permissions(
group_model: group_model_query_dependency,
org_model: org_model_root_claim_query_dependency,
):
"""
Gets a list of permissions granted to the group. Also returns a summary for the org and group.
"""
if group_model.org_id != org_model.id:
raise ForbiddenException("Group does not belong to this organization")
return {
"organisation": org_model,
"group": group_model,
"permissions": group_model.permission_rel,
}
@router.get(
path="/group/users",
summary="Gets a list of users assigned to a group",
status_code=status.HTTP_200_OK,
response_model=IAMGetGroupUsersResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": "Group does not belong to this organization"
},
},
)
async def get_group_users(
group_model: group_model_query_dependency,
org_model: org_model_root_claim_query_dependency,
):
"""
Gets a list of users assigned to the group. Also returns a summary for the org and group.
"""
if group_model.org_id != org_model.id:
raise ForbiddenException("Group does not belong to this organization")
return {
"organisation": org_model,
"group": group_model,
"users": group_model.user_rel,
}
@router.post(
path="/group",
summary="Creates a new group",
status_code=status.HTTP_201_CREATED,
response_model=IAMPostGroupResponse,
responses={
status.HTTP_409_CONFLICT: {
"description": "Group with this name already exists"
},
},
)
async def create_group(
db: db_dependency,
org_model: org_model_root_claim_body_dependency,
request_model: IAMPostGroupRequest,
):
"""
Creates a new IAM group.
"""
group_model = Group(name=request_model.name, org_id=org_model.id)
db.add(group_model)
try:
db.flush()
except IntegrityError as e:
if (
getattr(e.orig, "pgcode", None) == "23505" # Postgres unique violation
or "UNIQUE constraint failed" in str(e.orig) # SQLite unique violation
):
raise ConflictException("Group with this name already exists")
group_response = GroupSchema(**group_model.__dict__)
org_response = OrgSummary(**org_model.__dict__)
db.commit()
return {"group": group_response, "organisation": org_response}
@router.put(
path="/group/permission",
summary="Grants a permission to a group",
status_code=status.HTTP_200_OK,
response_model=IAMPutGroupPermissionResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": "Group does not belong to this organization"
},
status.HTTP_409_CONFLICT: {
"description": "This permission is already granted to this group"
},
},
)
async def add_group_permission(
db: db_dependency,
group_model: group_model_body_dependency,
perm_model: perm_model_body_dependency,
org_model: org_model_root_claim_body_dependency,
request_model: IAMPutGroupPermissionRequest,
):
"""
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.
"""
if group_model.org_id != org_model.id:
raise ForbiddenException("Group does not belong to this organization")
if perm_model in group_model.permission_rel:
raise ConflictException("Group already has this permission")
group_model.permission_rel.append(perm_model)
db.flush()
response = IAMPutGroupPermissionResponse(
organisation=OrgSummary(**org_model.__dict__),
group=GroupSummary(**group_model.__dict__),
permissions=group_model.permission_rel,
)
db.commit()
return response
@router.put(
path="/group/user",
summary="Directly adds a user to the group",
status_code=status.HTTP_200_OK,
response_model=IAMPutGroupUserResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": "Group not in org | User not authenticated | User does not have permission"
},
status.HTTP_409_CONFLICT: {"description": "User is already in group"},
status.HTTP_403_FORBIDDEN: {
"description": "Only existing org members can be added directly."
},
},
)
async def add_group_user(
db: db_dependency,
group_model: group_model_body_dependency,
user_model: user_model_body_dependency,
org_model: org_model_root_claim_body_dependency,
request_model: IAMPutGroupUserRequest,
):
"""
Directly adds an organisation member to a group.\n
To add a non-member, use an email invitation instead.\n
The user's email address must match the email on their OIDC profile.
"""
if group_model.org_id != org_model.id:
raise ForbiddenException("Group does not belong to this organization")
if user_model in group_model.user_rel:
raise ConflictException("User already in group")
if user_model not in org_model.user_rel:
raise ForbiddenException(
"Adding users directly can only be done with org members. Use email invitation instead."
)
group_model.user_rel.append(user_model)
db.flush()
response = IAMPutGroupUserResponse(
group=GroupSchema(**group_model.__dict__), users=group_model.user_rel
)
db.commit()
return response
@router.delete(
path="/group/permission",
summary="Removes a permission from the group",
status_code=status.HTTP_200_OK,
response_model=IAMDeleteGroupPermissionResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": "Group not in org | User not authenticated | User does not have permission"
},
},
)
async def remove_group_permission(
db: db_dependency,
group_model: group_model_query_dependency,
perm_model: perm_model_query_dependency,
org_model: org_model_root_claim_query_dependency,
):
"""
Removes a permission from the group.
"""
if group_model.org_id != org_model.id:
raise ForbiddenException("Group does not belong to this organization")
if perm_model not in group_model.permission_rel:
raise UnprocessableContentException("Permission not granted to group")
group_model.permission_rel.remove(perm_model)
db.flush()
response = IAMDeleteGroupPermissionResponse(
group=GroupSchema(**group_model.__dict__),
permissions=group_model.permission_rel,
)
db.commit()
return response
@router.delete(
path="/group/user",
summary="Removes a user from the group",
status_code=status.HTTP_200_OK,
response_model=IAMDeleteGroupUserResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": "User not authenticated | User does not have permission"
},
status.HTTP_403_FORBIDDEN: {"description": "Group not in org"},
},
)
async def remove_group_user(
db: db_dependency,
group_model: group_model_query_dependency,
user_model: user_model_query_dependency,
org_model: org_model_root_claim_query_dependency,
):
"""
Removes a user from the group.
"""
if group_model.org_id != org_model.id:
raise ForbiddenException("Group does not belong to this organization")
user_model.group_rel.remove(group_model)
db.flush()
response = IAMDeleteGroupUserResponse(
group=GroupSchema(**group_model.__dict__), users=group_model.user_rel
)
db.commit()
return response
@router.get(
path="/permissions",
summary="Returns a full list of permissions",
status_code=status.HTTP_200_OK,
response_model=IAMGetPermissionsResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {
"description": "User must be root user of an organisation."
},
},
)
async def get_permissions(
db: db_dependency, org_model: org_model_root_claim_query_dependency
):
"""
Returns a full list of permissions.
"""
permission_models = db.query(Perm).all()
return {"permissions": permission_models}
@router.post(
path="/permission",
summary="Creates a new permission for a service",
status_code=status.HTTP_201_CREATED,
response_model=IAMPostPermissionResponse,
responses={
status.HTTP_401_UNAUTHORIZED: {"description": "Must be super user."},
status.HTTP_404_NOT_FOUND: {"description": "Service does not exist"},
status.HTTP_409_CONFLICT: {"description": "Permission already exists"},
},
)
async def create_new_permission(
db: db_dependency,
su: super_admin_dependency,
request_model: IAMPostPermissionRequest,
):
"""
Allows a super admin to create a new IAM permission for a service.
"""
service_model = db.get(Service, request_model.service_id)
if service_model is None:
raise ServiceNotFoundException(service_id=request_model.service_id)
perm_model = Perm(**request_model.__dict__)
db.add(perm_model)
try:
db.flush()
except IntegrityError as e:
if (
getattr(e.orig, "pgcode", None) == "23505" # Postgres unique violation
or "UNIQUE constraint failed" in str(e.orig) # SQLite unique violation
):
raise ConflictException(message="Permission already exists")
response = {
"id": perm_model.id,
"service_name": perm_model.service_name,
"resource": perm_model.resource,
"action": perm_model.action,
}
db.commit()
return {"permission": response}
@router.delete(
path="/permission",
summary="Deletes a permission",
status_code=status.HTTP_204_NO_CONTENT,
responses={},
)
async def delete_permission(
db: db_dependency,
su: super_admin_dependency,
perm_model: perm_model_query_dependency,
):
"""
Allows a super admin to remove a permission.
"""
db.delete(perm_model)
db.commit()
@router.post(
path="/permissions/search",
summary="Search list of permissions",
status_code=status.HTTP_200_OK,
response_model=IAMGetPermissionsSearchResponse,
responses={},
)
async def post_permissions(
db: db_dependency,
org_model: org_model_root_claim_body_dependency,
request_model: IAMGetPermissionsSearchRequest,
):
"""
Returns a list of permissions filtered by the queries provided.\n
If a query is null, it will be ignored.
"""
permission_query = db.query(Perm)
if not (request_model.service_id is None or request_model.service_id == ""):
permission_query = permission_query.filter(
Perm.service_id == request_model.service_id
)
if not (request_model.resource is None or request_model.resource == ""):
permission_query = permission_query.filter(
Perm.resource == request_model.resource
)
if not (request_model.action is None or request_model.action == ""):
permission_query = permission_query.filter(Perm.action == request_model.action)
permission_models = permission_query.all()
return {"permissions": permission_models}
@router.put(
path="/group/user/invitation",
summary="Send an email invitation for non-org member to join a group",
status_code=status.HTTP_200_OK,
responses={},
)
async def invitation(
background_tasks: BackgroundTasks,
org_model: org_model_root_claim_body_dependency,
group_model: group_model_body_dependency,
request_model: IAMPutGroupInvitationRequest,
):
"""
Sends an email invitation to join a group.\n
This is intended for inviting no-members to a group, giving them permission to access org resources.\n
i.e. Allowing somebody in a partner organisation to view metrics.\n
Can also be used for inviting organisaion members if needed.
"""
org_id: int = org_model.id
org_name: str = org_model.name
user_email = request_model.user_email
group_id: int = group_model.id
group_name: str = group_model.name
background_tasks.add_task(
send_user_group_invitation,
org_id=org_id,
org_name=org_name,
user_email=user_email,
group_id=group_id,
group_name=group_name,
)
return "Invitation sent"
@router.put(
path="/group/user//invitation/accept",
summary="Accept email invitation to join an org's group",
status_code=status.HTTP_200_OK,
responses={
status.HTTP_404_NOT_FOUND: {"description": "User|Org|Group not found"},
status.HTTP_403_FORBIDDEN: {
"description": "Group and organisation do not match"
},
status.HTTP_409_CONFLICT: {"description": "User is already in the group"},
},
)
async def accept_invitation(
db: db_dependency,
user_model: user_model_claims_dependency,
request_model: IAMPutGroupInvitationAcceptRequest,
):
"""
Accepts an invitation to join an org's group
"""
email_claims = await verify_email_token(
token=request_model.jwt, user_model=user_model
)
org_model = db.get(Org, email_claims["org_id"])
if org_model is None:
raise OrgNotFoundException(email_claims["org_id"])
group_model = db.get(Group, email_claims["group_id"])
if group_model is None:
raise GroupNotFoundException(email_claims["group_id"])
if group_model not in org_model.group_rel:
raise ForbiddenException("Group and org do not match.")
if user_model in group_model.user_rel:
raise ConflictException("User already in group.")
group_model.user_rel.append(user_model)
db.commit()
return "Invitation accepted"