forked from sr2/cloud-api
649 lines
18 KiB
Python
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"
|