diff --git a/engine/.dockerignore b/engine/.dockerignore index e69de29..46cc430 100644 --- a/engine/.dockerignore +++ b/engine/.dockerignore @@ -0,0 +1 @@ +local.settings.json diff --git a/engine/.gitignore b/engine/.gitignore index ffa8418..563224d 100644 --- a/engine/.gitignore +++ b/engine/.gitignore @@ -24,3 +24,53 @@ archive.zip # vim temporary files *~ .*.sw? + +# Azure Functions +bin +obj +csx +.vs +edge +Publish + +*.user +*.suo +*.cscfg +*.Cache +project.lock.json + +/packages +/TestResults + +/tools/NuGet.exe +/App_Data +/secrets +/data +.secrets +appsettings.json +local.settings.json + +node_modules +dist + +# Local python packages +.python_packages/ + +# Python Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Azurite artifacts +__blobstorage__ +__queuestorage__ +__azurite_db*__.json diff --git a/engine/Dockerfile.func b/engine/Dockerfile.func new file mode 100644 index 0000000..e5f211d --- /dev/null +++ b/engine/Dockerfile.func @@ -0,0 +1,11 @@ +# To enable ssh & remote debugging on app service change the base image to the one below +# FROM mcr.microsoft.com/azure-functions/python:3.0-python3.9-appservice +FROM mcr.microsoft.com/azure-functions/python:4-python3.9 + +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +COPY requirements.txt / +RUN pip install -r /requirements.txt + +COPY . /home/site/wwwroot diff --git a/engine/app/dependencies.py b/engine/app/dependencies.py index 4448d5f..983ca73 100644 --- a/engine/app/dependencies.py +++ b/engine/app/dependencies.py @@ -4,6 +4,7 @@ from azure.cosmos.aio import CosmosClient import jwt import time +import copy from app.routers.common.helper import ( cosmos_query @@ -28,13 +29,18 @@ async def check_token_expired(request: Request): request.state.tenant_id = decoded['tid'] - await check_admin(request, decoded['oid']) + await check_admin(request, decoded['oid'], decoded['tid']) -async def check_admin(request: Request, user_oid: str): - item = await cosmos_query("admins") +async def check_admin(request: Request, user_oid: str, user_tid: str): + admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", user_tid) - if item['admins']: - is_admin = next((x for x in item['admins'] if user_oid == x['id']), None) + if admin_query: + admin_data = copy.deepcopy(admin_query[0]) + + if admin_data['admins']: + is_admin = next((x for x in admin_data['admins'] if user_oid == x['id']), None) + else: + is_admin = True else: is_admin = True diff --git a/engine/app/main.py b/engine/app/main.py index 51ec9d8..3dd7ceb 100644 --- a/engine/app/main.py +++ b/engine/app/main.py @@ -1,4 +1,3 @@ -from audioop import tostereo from fastapi import FastAPI, Request, HTTPException, Header from fastapi.responses import JSONResponse, RedirectResponse, FileResponse from fastapi.staticfiles import StaticFiles diff --git a/engine/app/models.py b/engine/app/models.py index fbeb9de..fe667d6 100644 --- a/engine/app/models.py +++ b/engine/app/models.py @@ -2,8 +2,8 @@ from pydantic import BaseModel, ValidationError, EmailStr from typing import Optional, List, Any from netaddr import IPSet, IPNetwork, IPAddress -# from ipaddress import IPv4Network, IPv4Address from datetime import datetime +from uuid import UUID import json class IPv4Network(str): @@ -68,71 +68,6 @@ class IPv4Address(str): def __repr__(self): return f'IPAddress({super().__repr__()})' -# class VNet(BaseModel): -# """DOCSTRING""" - -# vnet: str - -# class IPReservation(BaseModel): -# """DOCSTRING""" - -# cidr: IPv4Address -# userId: EmailStr -# createdOn: datetime - -# class Config: -# json_encoders = { -# datetime: lambda v: v.timestamp(), -# IPAddress: lambda v: str(v), -# } - -# class Reservation(BaseModel): -# """DOCSTRING""" - -# id: str -# cidr: str -# userId: str #EmailStr -# createdOn: float -# status: str - -# class BlockReq(BaseModel): -# """DOCSTRING""" - -# name: str -# cidr: IPv4Network - -# class Config: -# json_encoders = { -# IPNetwork: lambda v: str(v), -# } - -# class BlockRes(BaseModel): -# """DOCSTRING""" - -# name: str -# cidr: IPv4Network -# vnets: List -# resv: List - -# class Config: -# json_encoders = { -# IPNetwork: lambda v: str(v), -# } - -# class SpaceReq(BaseModel): -# """DOCSTRING""" - -# name: str -# desc: str - -# class SpaceRes(BaseModel): -# """DOCSTRING""" - -# name: str -# desc: str -# vnets: List[str] -# blocks: List[BlockRes] - ###################### # REQUEST MODELS # ###################### @@ -333,3 +268,43 @@ class SpaceExpandUtil(BaseModel): blocks: List[BlockExpandUtil] size: int used: int + +#################### +# ADMIN MODELS # +#################### + +class Admin(BaseModel): + name: str + email: EmailStr + id: UUID + + class Config: + json_encoders = { + UUID: lambda v: str(v), + } + +################### +# USER MODELS # +################### + +class User(BaseModel): + """DOCSTRING""" + + id: UUID + apiRefresh: int + isAdmin: bool + + class Config: + json_encoders = { + UUID: lambda v: str(v), + } + +class JSONPatch(BaseModel): + """DOCSTRING""" + + op: str + path: str + value: Any + +class UserUpdate(List[JSONPatch]): + """DOCSTRING""" diff --git a/engine/app/routers/admin.py b/engine/app/routers/admin.py index 3d59dc6..fae3f65 100644 --- a/engine/app/routers/admin.py +++ b/engine/app/routers/admin.py @@ -3,18 +3,28 @@ from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.exceptions import HTTPException as StarletteHTTPException from fastapi.encoders import jsonable_encoder +import azure.cosmos.exceptions as exceptions + from pydantic import BaseModel, EmailStr, constr from typing import Optional, List -import azure.cosmos.exceptions as exceptions +import copy +import uuid -from app.dependencies import check_token_expired, get_admin +from app.dependencies import ( + check_token_expired, + get_admin, + get_tenant_id +) -from uuid import UUID +from app.models import * from app.routers.common.helper import ( cosmos_query, - cosmos_upsert + cosmos_upsert, + cosmos_replace, + cosmos_delete, + cosmos_retry ) router = APIRouter( @@ -23,21 +33,12 @@ router = APIRouter( dependencies=[Depends(check_token_expired)] ) -class Admin(BaseModel): - name: str - email: EmailStr - id: UUID - - class Config: - json_encoders = { - UUID: lambda v: str(v), - } - @router.get( "", summary = "Get All Admins" ) async def get_admins( + tenant_id: str = Depends(get_tenant_id), is_admin: str = Depends(get_admin) ): """ @@ -47,17 +48,27 @@ async def get_admins( if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - item = await cosmos_query("admins") + admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id) - return item['admins'] + if admin_query: + admin_data = copy.deepcopy(admin_query[0]) + + return admin_data['admins'] + else: + return [] @router.post( "", summary = "Create IPAM Admin", status_code=201 ) +@cosmos_retry( + max_retry = 5, + error_msg = "Error creating admin, please try again." +) async def create_admin( admin: Admin, + tenant_id: str = Depends(get_tenant_id), is_admin: str = Depends(get_admin) ): """ @@ -68,32 +79,32 @@ async def create_admin( - **id**: Azure AD ObjectID for the Administrator user """ - current_try = 0 - max_retry = 5 - if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - while True: - try: - item = await cosmos_query("admins") + admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id) - target_admin = next((x for x in item['admins'] if x['id'] == admin.id), None) + if not admin_query: + admin_data = { + "id": uuid.uuid4(), + "type": "admin", + "tenant_id": tenant_id, + "admins": [admin], + "excluded": [] + } - if target_admin: - raise HTTPException(status_code=400, detail="User is already an admin.") + await cosmos_upsert(jsonable_encoder(admin_data)) + else: + admin_data = copy.deepcopy(admin_query[0]) - item['admins'].append(jsonable_encoder(admin)) + target_admin = next((x for x in admin_data['admins'] if x['id'] == admin.id), None) - await cosmos_upsert("admins", item) - except exceptions.CosmosAccessConditionFailedError: - if current_try < max_retry: - current_try += 1 - continue - else: - raise HTTPException(status_code=500, detail="Error creating admin, please try again.") - else: - break + if target_admin: + raise HTTPException(status_code=400, detail="User is already an admin.") + + admin_data['admins'].append(jsonable_encoder(admin)) + + await cosmos_replace(admin_query[0], admin_data) return Response(status_code=status.HTTP_201_CREATED) @@ -102,40 +113,33 @@ async def create_admin( summary = "Delete IPAM Admin", status_code=200 ) +@cosmos_retry( + max_retry = 5, + error_msg = "Error removing admin, please try again." +) async def delete_admin( objectId: UUID, + tenant_id: str = Depends(get_tenant_id), is_admin: str = Depends(get_admin) ): """ Remove a specific IPAM Administrator """ - current_try = 0 - max_retry = 5 - if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - while True: - try: - item = await cosmos_query("admins") + admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id) + admin_data = copy.deepcopy(admin_query[0]) - admin_index = next((i for i, admin in enumerate(item['admins']) if admin['id'] == str(objectId)), None) + admin_index = next((i for i, admin in enumerate(admin_data['admins']) if admin['id'] == str(objectId)), None) - if not admin_index: - raise HTTPException(status_code=400, detail="Invalid admin objectId.") + if admin_index is None: + raise HTTPException(status_code=400, detail="Invalid admin objectId.") - del item['admins'][admin_index] + del admin_data['admins'][admin_index] - await cosmos_upsert("admins", item) - except exceptions.CosmosAccessConditionFailedError: - if current_try < max_retry: - current_try += 1 - continue - else: - raise HTTPException(status_code=500, detail="Error removing admin, please try again.") - else: - break + await cosmos_replace(admin_query[0], admin_data) return Response(status_code=status.HTTP_200_OK) @@ -144,8 +148,13 @@ async def delete_admin( summary = "Replace IPAM Admins", status_code=200 ) +@cosmos_retry( + max_retry = 5, + error_msg = "Error updating admins, please try again." +) async def update_admins( admin_list: List[Admin], + tenant_id: str = Depends(get_tenant_id), is_admin: str = Depends(get_admin) ): """ @@ -157,31 +166,32 @@ async def update_admins( - **id**: Azure AD ObjectID for the Administrator user """ - current_try = 0 - max_retry = 5 - if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - while True: - try: - item = await cosmos_query("admins") + id_list = [x.id for x in admin_list] + unique_admins = len(set(id_list)) == len(admin_list) - id_list = [x.id for x in admin_list] - unique_admins = len(set(id_list)) == len(admin_list) + if not unique_admins: + raise HTTPException(status_code=400, detail="List contains one or more duplicate objectId's.") - if not unique_admins: - raise HTTPException(status_code=400, detail="List contains one or more duplicate objectId's.") + admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id) - item['admins'] = jsonable_encoder(admin_list) - await cosmos_upsert("admins", item) - except exceptions.CosmosAccessConditionFailedError: - if current_try < max_retry: - current_try += 1 - continue - else: - raise HTTPException(status_code=500, detail="Error updating admins, please try again.") - else: - break + if not admin_query: + admin_data = { + "id": uuid.uuid4(), + "type": "admin", + "tenant_id": tenant_id, + "admins": admin_list, + "excluded": [] + } + + await cosmos_upsert(jsonable_encoder(admin_data)) + else: + admin_data = copy.deepcopy(admin_query[0]) + + admin_data['admins'] = jsonable_encoder(admin_list) + + await cosmos_replace(admin_query[0], admin_data) return PlainTextResponse(status_code=status.HTTP_200_OK) diff --git a/engine/app/routers/argquery.py b/engine/app/routers/argquery.py index b9440fa..c0ddf5a 100644 --- a/engine/app/routers/argquery.py +++ b/engine/app/routers/argquery.py @@ -9,7 +9,13 @@ resources SUBSCRIPTION = """ ResourceContainers | where type =~ 'microsoft.resources/subscriptions' -| project name, id, tenant_id = tenantId, subscription_id = subscriptionId +| extend quotaId = properties.subscriptionPolicies.quotaId +| extend type = case( + quotaId startswith "EnterpriseAgreement", "Enterprise Agreement", + quotaId startswith "MSDNDevTest", "Dev/Test", + "Unknown" +) +| project name, id, type, subscription_id = subscriptionId, tenant_id = tenantId """ SPACE = """ diff --git a/engine/app/routers/azure.py b/engine/app/routers/azure.py index 117cf5b..fe53f87 100644 --- a/engine/app/routers/azure.py +++ b/engine/app/routers/azure.py @@ -21,9 +21,9 @@ from uuid import uuid4 from sqlalchemy import true from app.dependencies import ( - check_token_expired, - get_admin, - get_tenant_id + check_token_expired, + get_admin, + get_tenant_id ) from . import argquery @@ -31,9 +31,9 @@ from . import argquery from app.routers.common.helper import ( get_client_credentials, get_obo_credentials, - cosmos_query_x, - cosmos_upsert_x, - cosmos_replace_x, + cosmos_query, + cosmos_upsert, + cosmos_replace, cosmos_retry, arg_query ) @@ -193,12 +193,12 @@ async def get_vmss_interfaces_sdk_helper(credentials, vmss, list): await network_client.close() @router.get( - "/subscription", - summary = "Get All Subscriptions" + "/subscription", + summary = "Get All Subscriptions" ) async def subscription( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of Azure subscriptions. @@ -209,19 +209,19 @@ async def subscription( return subscription_list @router.get( - "/vnet", - summary = "Get All Virtual Networks" + "/vnet", + summary = "Get All Virtual Networks" ) async def get_vnet( - authorization: str = Header(None), - admin: str = Depends(get_admin), - tenant_id: str = Depends(get_tenant_id) + authorization: str = Header(None), + tenant_id: str = Depends(get_tenant_id), + admin: str = Depends(get_admin) ): """ Get a list of Azure Virtual Networks. """ - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space'", tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space'", tenant_id) vnet_list = await arg_query(authorization, admin, argquery.VNET) @@ -255,12 +255,12 @@ async def get_vnet( return updated_vnet_list @router.get( - "/subnet", - summary = "Get All Subnets" + "/subnet", + summary = "Get All Subnets" ) async def get_subnet( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of Azure Subnets. @@ -316,12 +316,12 @@ async def get_subnet( return updated_subnet_list @router.get( - "/pe", - summary = "Get All Private Endpoints" + "/pe", + summary = "Get All Private Endpoints" ) async def pe( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of Azure Private Endpoints. @@ -332,12 +332,12 @@ async def pe( return pe_list @router.get( - "/vm", - summary = "Get All Virtual Machines" + "/vm", + summary = "Get All Virtual Machines" ) async def vm( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of Azure Virtual Machines @@ -348,12 +348,12 @@ async def vm( return vm_list @router.get( - "/vmss", - summary = "Get All VM Scale Sets" + "/vmss", + summary = "Get All VM Scale Sets" ) async def vmss( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of Azure VM Scale Sets. @@ -365,12 +365,12 @@ async def vmss( return vmss_list @router.get( - "/fwvnet", - summary = "Get All vNet Firewalls" + "/fwvnet", + summary = "Get All vNet Firewalls" ) async def fwvnet( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of vNet integrated Azure Firewalls. @@ -381,12 +381,12 @@ async def fwvnet( return vm_list @router.get( - "/fwvhub", - summary = "Get all vWAN Hub Firewalls" + "/fwvhub", + summary = "Get all vWAN Hub Firewalls" ) async def fwvhub( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of all vWAN Hub integrated Azure Firewalls. @@ -397,12 +397,12 @@ async def fwvhub( return vm_list @router.get( - "/bastion", - summary = "Get All Bastion Hosts" + "/bastion", + summary = "Get All Bastion Hosts" ) async def bastion( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of all Azure Bastions hosts. @@ -413,12 +413,12 @@ async def bastion( return vm_list @router.get( - "/appgw", - summary = "Get All Application Gateways" + "/appgw", + summary = "Get All Application Gateways" ) async def appgw( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of all Azure Application Gateways. @@ -429,12 +429,12 @@ async def appgw( return vm_list @router.get( - "/apim", - summary = "Get All API Management Instances" + "/apim", + summary = "Get All API Management Instances" ) async def apim( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a list of all Azure API Management instances. @@ -451,12 +451,12 @@ async def multi_helper(func, list, *args): list.append(results) @router.get( - "/multi", - summary = "Get All Endpoints" + "/multi", + summary = "Get All Endpoints" ) async def multi( - authorization: str = Header(None), - admin: str = Depends(get_admin) + authorization: str = Header(None), + admin: str = Depends(get_admin) ): """ Get a consolidated list of all Azure endpoints. @@ -478,13 +478,13 @@ async def multi( return [item for sublist in result_list for item in sublist] @router.get( - "/tree", - summary = "Get Space Tree View" + "/tree", + summary = "Get Space Tree View" ) async def multi( - authorization: str = Header(None), - admin: str = Depends(get_admin), - tenant_id: str = Depends(get_tenant_id) + authorization: str = Header(None), + tenant_id: str = Depends(get_tenant_id), + admin: str = Depends(get_admin) ): """ Get a hierarchical tree view of Spaces, Blocks, Virtual Networks, Subnets, and Endpoints. @@ -497,7 +497,7 @@ async def multi( endpoint_list = [] tasks.append(asyncio.create_task(multi_helper(get_spaces, space_list, False, True, authorization, tenant_id, True))) - tasks.append(asyncio.create_task(multi_helper(get_vnet, vnet_list, authorization, admin))) + tasks.append(asyncio.create_task(multi_helper(get_vnet, vnet_list, authorization, tenant_id, admin))) tasks.append(asyncio.create_task(multi_helper(get_subnet, subnet_list, authorization, admin))) tasks.append(asyncio.create_task(multi_helper(pe, endpoint_list, authorization, admin))) tasks.append(asyncio.create_task(multi_helper(vm, endpoint_list, authorization, admin))) @@ -599,7 +599,7 @@ async def match_resv_to_vnets(): vnet_list = await arg_query(None, True, argquery.VNET) stale_resv = list(x['resv'] for x in vnet_list if x['resv'] != None) - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space'", globals.TENANT_ID) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space'", globals.TENANT_ID) for space in space_query: original_space = copy.deepcopy(space) @@ -623,6 +623,7 @@ async def match_resv_to_vnets(): # print("INDEX: {}".format(index)) stale_resv.remove(resv['id']) + resv['status'] = "wait" cidr_match = resv['cidr'] in vnet['prefixes'] @@ -646,6 +647,7 @@ async def match_resv_to_vnets(): resv['status'] = "errCIDRExists" if resv['status'] == "wait": + # print("vNET is being added to IP Block...") block['vnets'].append( { "id": vnet['id'], @@ -653,10 +655,8 @@ async def match_resv_to_vnets(): } ) del block['resv'][index] - else: - resv['status'] = "wait" - await cosmos_replace_x(original_space, space) + await cosmos_replace(original_space, space) # print("STALE:") # print(stale_resv) diff --git a/engine/app/routers/common/helper.py b/engine/app/routers/common/helper.py index d92413a..fb08e06 100644 --- a/engine/app/routers/common/helper.py +++ b/engine/app/routers/common/helper.py @@ -14,6 +14,8 @@ import os import jwt from functools import wraps +from requests import options + import app.globals as globals SCOPE = "https://management.azure.com/user_impersonation" @@ -62,23 +64,47 @@ async def get_obo_credentials(assertion): return credential -async def cosmos_query(target: str): - """DOCSTRING""" - cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) +# async def cosmos_query(target: str): +# """DOCSTRING""" +# cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) - database_name = "ipam-db" - database = cosmos_client.get_database_client(database_name) +# database_name = "ipam-db" +# database = cosmos_client.get_database_client(database_name) - container_name = "ipam-container" - container = database.get_container_client(container_name) +# container_name = "ipam-container" +# container = database.get_container_client(container_name) - item = await container.read_item(target, partition_key=target) +# item = await container.read_item(target, partition_key=target) - await cosmos_client.close() +# await cosmos_client.close() - return item +# return item -async def cosmos_query_x(query: str, tenant_id: str): +# async def cosmos_upsert(target: str, data): +# """DOCSTRING""" + +# cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) + +# database_name = "ipam-db" +# database = cosmos_client.get_database_client(database_name) + +# container_name = "ipam-container" +# container = database.get_container_client(container_name) + +# try: +# await container.upsert_item( +# data, +# match_condition=MatchConditions.IfNotModified, +# etag=data['_etag'] +# ) +# except: +# raise +# finally: +# await cosmos_client.close() + +# return + +async def cosmos_query(query: str, tenant_id: str): """DOCSTRING""" result_array = [] @@ -98,13 +124,13 @@ async def cosmos_query_x(query: str, tenant_id: str): ) async for result in query_results: - result_array.append(result) + result_array.append(result) await cosmos_client.close() return result_array -async def cosmos_upsert_x(data): +async def cosmos_upsert(data): """DOCSTRING""" cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) @@ -116,7 +142,7 @@ async def cosmos_upsert_x(data): container = database.get_container_client(container_name) try: - await container.upsert_item(data) + res = await container.upsert_item(data) except: raise finally: @@ -124,33 +150,9 @@ async def cosmos_upsert_x(data): await cosmos_client.close() - return + return res -async def cosmos_upsert(target: str, data): - """DOCSTRING""" - - cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) - - database_name = "ipam-db" - database = cosmos_client.get_database_client(database_name) - - container_name = "ipam-container" - container = database.get_container_client(container_name) - - try: - await container.upsert_item( - data, - match_condition=MatchConditions.IfNotModified, - etag=data['_etag'] - ) - except: - raise - finally: - await cosmos_client.close() - - return - -async def cosmos_replace_x(old, new): +async def cosmos_replace(old, new): """DOCSTRING""" cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) @@ -177,7 +179,7 @@ async def cosmos_replace_x(old, new): return -async def cosmos_delete_x(item, tenant_id: str): +async def cosmos_delete(item, tenant_id: str): """DOCSTRING""" cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) @@ -277,21 +279,33 @@ async def arg_query_obo(auth, query): async def arg_query_helper(credentials, query): """DOCSTRING""" + results = [] + resource_graph_client = ResourceGraphClient(credentials) - query = QueryRequest( - query=query, - management_groups=[globals.TENANT_ID], - options=QueryRequestOptions( - result_format=ResultFormat.object_array - ) - ) - try: - poll = await resource_graph_client.resources(query) + skip_token = None + + while True: + query = QueryRequest( + query=query, + management_groups=[globals.TENANT_ID], + options=QueryRequestOptions( + result_format=ResultFormat.object_array, + skip_token=skip_token + ) + ) + + poll = await resource_graph_client.resources(query) + results = results + poll.data + + if poll.skip_token: + skip_token = poll.skip_token + else: + break except ServiceRequestError: raise HTTPException(status_code=500, detail="Error communicating with Azure.") finally: await resource_graph_client.close() - return poll.data + return results diff --git a/engine/app/routers/space.py b/engine/app/routers/space.py index 3b078bb..2b077ea 100644 --- a/engine/app/routers/space.py +++ b/engine/app/routers/space.py @@ -30,11 +30,9 @@ from . import argquery from app.routers.common.helper import ( get_username_from_jwt, cosmos_query, - cosmos_query_x, - cosmos_upsert_x, - cosmos_replace_x, - cosmos_delete_x, cosmos_upsert, + cosmos_replace, + cosmos_delete, cosmos_retry, arg_query ) @@ -111,7 +109,7 @@ async def get_spaces( if expand or utilization: vnets = await arg_query(authorization, True, argquery.VNET) - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space'", tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space'", tenant_id) for space in space_query: if utilization: @@ -192,7 +190,7 @@ async def create_space( if not is_admin: raise HTTPException(status_code=403, detail="This API is admin restricted.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space'", tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space'", tenant_id) duplicate = next((x for x in space_query if x['name'].lower() == space.name.lower()), None) @@ -200,14 +198,14 @@ async def create_space( raise HTTPException(status_code=400, detail="Space name must be unique.") new_space = { - # "id": uuid.uuid4(), + "id": uuid.uuid4(), "type": "space", "tenant_id": tenant_id, **space.dict(), "blocks": [] } - await cosmos_upsert_x(jsonable_encoder(new_space)) + await cosmos_upsert(jsonable_encoder(new_space)) return new_space @@ -241,7 +239,7 @@ async def get_space( if expand and not is_admin: raise HTTPException(status_code=403, detail="Expand parameter can only be used by admins.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -337,7 +335,7 @@ async def update_space( if not is_admin: raise HTTPException(status_code=403, detail="This API is admin restricted.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -352,7 +350,7 @@ async def update_space( scrubbed_patch = jsonpatch.JsonPatch(await scrub_space_patch(patch)) update_space = scrubbed_patch.apply(target_space) - await cosmos_replace_x(target_space, update_space) + await cosmos_replace(target_space, update_space) return update_space @@ -378,7 +376,7 @@ async def delete_space( if not is_admin: raise HTTPException(status_code=403, detail="This API is admin restricted.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -389,7 +387,7 @@ async def delete_space( if len(target_space['blocks']) > 0: raise HTTPException(status_code=400, detail="Cannot delete space while it contains blocks.") - await cosmos_delete_x(target_space, tenant_id) + await cosmos_delete(target_space, tenant_id) return PlainTextResponse(status_code=status.HTTP_200_OK) @@ -423,7 +421,7 @@ async def get_blocks( if expand and not is_admin: raise HTTPException(status_code=403, detail="Expand parameter can only be used by admins.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -507,7 +505,7 @@ async def create_block( if not is_admin: raise HTTPException(status_code=403, detail="This API is admin restricted.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -529,7 +527,7 @@ async def create_block( target_space['blocks'].append(jsonable_encoder(new_block)) - await cosmos_replace_x(space_query[0], target_space) + await cosmos_replace(space_query[0], target_space) return new_block @@ -564,7 +562,7 @@ async def get_block( if expand and not is_admin: raise HTTPException(status_code=403, detail="Expand parameter can only be used by admins.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -647,7 +645,7 @@ async def delete_block( if not is_admin: raise HTTPException(status_code=403, detail="This API is admin restricted.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -666,7 +664,7 @@ async def delete_block( index = next((i for i, item in enumerate(target_space['blocks']) if item['name'] == block), None) del target_space['blocks'][index] - await cosmos_replace_x(space_query[0], target_space) + await cosmos_replace(space_query[0], target_space) return PlainTextResponse(status_code=status.HTTP_200_OK) @@ -696,7 +694,7 @@ async def available_block_vnets( if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space'", tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space'", tenant_id) target_space = next((x for x in space_query if x['name'].lower() == space.lower()), None) @@ -761,7 +759,7 @@ async def available_block_vnets( if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -811,7 +809,7 @@ async def create_block_vnet( if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -853,9 +851,9 @@ async def create_block_vnet( raise HTTPException(status_code=400, detail="Block already contains vNet(s) within the CIDR range of target vNet.") vnet.active = True - target_block['vnets'].append(vnet) + target_block['vnets'].append(jsonable_encoder(vnet)) - await cosmos_replace_x(space_query[0], target_space) + await cosmos_replace(space_query[0], target_space) return target_block @@ -888,7 +886,7 @@ async def update_block_vnets( if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -949,7 +947,7 @@ async def update_block_vnets( target_block['vnets'] = new_vnet_list - await cosmos_replace_x(space_query[0], target_space) + await cosmos_replace(space_query[0], target_space) return target_block['vnets'] @@ -966,7 +964,7 @@ async def update_block_vnets( async def delete_block_vnets( space: str, block: str, - req: VNets, + req: VNetsUpdate, tenant_id: str = Depends(get_tenant_id), is_admin: str = Depends(get_admin) ): @@ -979,7 +977,7 @@ async def delete_block_vnets( if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -991,23 +989,23 @@ async def delete_block_vnets( if not target_block: raise HTTPException(status_code=400, detail="Invalid block name.") - unique_vnets = len(set(req.ids)) == len(req.ids) + unique_vnets = len(set(req)) == len(req) if not unique_vnets: raise HTTPException(status_code=400, detail="List contains one or more duplicate vNet id's.") current_vnets = list(x['id'] for x in target_block['vnets']) - ids_exist = all(elem in current_vnets for elem in req.ids) + ids_exist = all(elem in current_vnets for elem in req) if not ids_exist: raise HTTPException(status_code=400, detail="List contains one or more invalid vNet id's.") # OR VNET IDS THAT DON'T BELONG TO THE CURRENT BLOCK - for id in req.ids: + for id in req: index = next((i for i, item in enumerate(target_block['vnets']) if item['id'] == id), None) del target_block['vnets'][index] - await cosmos_replace_x(space_query[0], target_space) + await cosmos_replace(space_query[0], target_space) return PlainTextResponse(status_code=status.HTTP_200_OK) @@ -1030,7 +1028,7 @@ async def get_block_reservations( user_assertion = authorization.split(' ')[1] - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -1074,7 +1072,7 @@ async def create_block_reservation( user_assertion = authorization.split(' ')[1] decoded = jwt.decode(user_assertion, options={"verify_signature": False}) - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -1118,7 +1116,7 @@ async def create_block_reservation( "id": shortuuid.uuid(), "cidr": str(next_cidr), "userId": creator_id, - "createdOn": (time.time() * 1000), + "createdOn": time.time(), "status": "wait" } @@ -1126,7 +1124,7 @@ async def create_block_reservation( # NEED TO RETURN GUID FOR USER TO APPEND TO AZURE TAG ON VNET - await cosmos_replace_x(space_query[0], target_space) + await cosmos_replace(space_query[0], target_space) return new_cidr @@ -1148,13 +1146,15 @@ async def delete_block_reservations( is_admin: str = Depends(get_admin) ): """ - Remove a CIDR Reservation for the target Block. + Remove one or more CIDR Reservations for the target Block. + + - **[<str>]**: Array of CIDR Reservation ID's """ user_assertion = authorization.split(' ')[1] user_name = get_username_from_jwt(user_assertion) - space_query = await cosmos_query_x("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) + space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id) try: target_space = copy.deepcopy(space_query[0]) @@ -1187,6 +1187,6 @@ async def delete_block_reservations( index = next((i for i, item in enumerate(target_block['resv']) if item['id'] == id), None) del target_block['resv'][index] - await cosmos_replace_x(space_query[0], target_space) + await cosmos_replace(space_query[0], target_space) return PlainTextResponse(status_code=status.HTTP_200_OK) diff --git a/engine/app/routers/user.py b/engine/app/routers/user.py index 1226832..fe190fc 100644 --- a/engine/app/routers/user.py +++ b/engine/app/routers/user.py @@ -8,17 +8,27 @@ from typing import Optional, List, Any import azure.cosmos.exceptions as exceptions -from app.dependencies import check_token_expired, get_admin +from app.dependencies import ( + check_token_expired, + get_admin, + get_tenant_id +) import re import jsonpatch -from uuid import UUID +import uuid +import copy + +from app.models import * from app.routers.common.helper import ( get_username_from_jwt, get_user_id_from_jwt, cosmos_query, - cosmos_upsert + cosmos_upsert, + cosmos_replace, + cosmos_delete, + cosmos_retry ) router = APIRouter( @@ -27,27 +37,20 @@ router = APIRouter( dependencies=[Depends(check_token_expired)] ) -class User(BaseModel): - """DOCSTRING""" +async def new_user(user_id, tenant_id): + new_user = { + "id": uuid.uuid4(), + "type": "user", + "tenant_id": tenant_id, + "data": { + "id": user_id, + "apiRefresh": 5 + } + } - id: UUID - apiRefresh: int - isAdmin: bool + query_results = await cosmos_upsert(jsonable_encoder(new_user)) - class Config: - json_encoders = { - UUID: lambda v: str(v), - } - -class JSONPatch(BaseModel): - """DOCSTRING""" - - op: str - path: str - value: Any - -class UserUpdate(List[JSONPatch]): - """DOCSTRING""" + return query_results async def scrub_patch(patch): scrubbed_patch = [] @@ -79,6 +82,7 @@ async def scrub_patch(patch): status_code = 200 ) async def get_users( + tenant_id: str = Depends(get_tenant_id), is_admin: str = Depends(get_admin) ): """ @@ -90,10 +94,12 @@ async def get_users( if not is_admin: raise HTTPException(status_code=403, detail="API restricted to admins.") - users = await cosmos_query("users") - admins = await cosmos_query("admins") + users = await cosmos_query("SELECT VALUE c.data FROM c WHERE c.type = 'user'", tenant_id) + admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id) - for user in users['users']: + admins = admin_query[0] + + for user in users: is_admin = next((x for x in admins['admins'] if x['id'] == user['id']), None) current_user = { @@ -111,53 +117,40 @@ async def get_users( response_model = User, status_code = 200 ) +@cosmos_retry( + max_retry = 5, + error_msg = "Error creating user, please try again." +) async def get_user( - authorization: str = Header(None) + authorization: str = Header(None), + tenant_id: str = Depends(get_tenant_id) ): """ Get your IPAM user details. """ user_assertion = authorization.split(' ')[1] - userId = get_user_id_from_jwt(user_assertion) + user_id = get_user_id_from_jwt(user_assertion) - current_try = 0 - max_retry = 5 + user_query = await cosmos_query("SELECT * FROM c WHERE (c.type = 'user' AND c['data']['id'] = '{}')".format(user_id), tenant_id) - while True: - try: - item = await cosmos_query("users") + if not user_query: + user_query = [await new_user(user_id, tenant_id)] - target_user = next((x for x in item['users'] if x['id'] == userId), None) + user_data = copy.deepcopy(user_query[0]) - if not target_user: - target_user = { - "id": userId, - "apiRefresh": 5 - } + admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id) - item['users'].append(target_user) - - await cosmos_upsert("users", item) - except exceptions.CosmosAccessConditionFailedError as e: - if current_try < max_retry: - current_try += 1 - continue - else: - raise HTTPException(status_code=500, detail="Error creating user, please try again.") - else: - break - - admins = await cosmos_query("admins") + admins = admin_query[0] if admins['admins']: - is_admin = next((x for x in admins['admins'] if x['id'] == target_user['id']), None) + is_admin = next((x for x in admins['admins'] if x['id'] == user_id), None) else: - is_admin = True + is_admin = True - target_user['isAdmin'] = True if is_admin else False + user_data['data']['isAdmin'] = True if is_admin else False - return target_user + return user_data['data'] @router.patch( "/me", @@ -165,9 +158,14 @@ async def get_user( response_model = User, status_code=200 ) +@cosmos_retry( + max_retry = 5, + error_msg = "Error updating user, please try again." +) async def update_user( updates: UserUpdate, - authorization: str = Header(None) + authorization: str = Header(None), + tenant_id: str = Depends(get_tenant_id) ): """ Update a User with a JSON patch: @@ -183,40 +181,32 @@ async def update_user( - **/apiRefresh** """ - current_try = 0 - max_retry = 5 - user_assertion = authorization.split(' ')[1] - userId = get_user_id_from_jwt(user_assertion) + user_id = get_user_id_from_jwt(user_assertion) - while True: - try: - item = await cosmos_query("users") + user_query = await cosmos_query("SELECT * FROM c WHERE (c.type = 'user' AND c['data']['id'] = '{}')".format(user_id), tenant_id) - target_user = next((x for x in item['users'] if x['id'] == userId), None) + if not user_query: + user_query = [await new_user(user_id, tenant_id)] - try: - patch = jsonpatch.JsonPatch(updates) - except jsonpatch.InvalidJsonPatch: - raise HTTPException(status_code=500, detail="Invalid JSON patch, please review and try again.") + user_data = copy.deepcopy(user_query[0]) - scrubbed_patch = jsonpatch.JsonPatch(await scrub_patch(patch)) - scrubbed_patch.apply(target_user, in_place = True) + try: + patch = jsonpatch.JsonPatch(updates) + except jsonpatch.InvalidJsonPatch: + raise HTTPException(status_code=500, detail="Invalid JSON patch, please review and try again.") - await cosmos_upsert("users", item) - except exceptions.CosmosAccessConditionFailedError: - if current_try < max_retry: - current_try += 1 - continue - else: - raise HTTPException(status_code=500, detail="Error updating user, please try again.") - else: - break + scrubbed_patch = jsonpatch.JsonPatch(await scrub_patch(patch)) + user_data['data'] = scrubbed_patch.apply(user_data['data'], in_place = True) - admins = await cosmos_query("admins") + await cosmos_replace(user_query[0], user_data) - is_admin = next((x for x in admins['admins'] if x['id'] == target_user['id']), None) + admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id) - target_user['isAdmin'] = True if is_admin else False + admins = admin_query[0] - return target_user + is_admin = next((x for x in admins['admins'] if x['id'] == user_id), None) + + user_data['data']['isAdmin'] = True if is_admin else False + + return user_data['data'] diff --git a/engine/host.json b/engine/host.json new file mode 100644 index 0000000..f292709 --- /dev/null +++ b/engine/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + }, + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[2.*, 3.0.0)" + } +} diff --git a/engine/ipam-func/__init__.py b/engine/ipam-func/__init__.py new file mode 100644 index 0000000..aa7d375 --- /dev/null +++ b/engine/ipam-func/__init__.py @@ -0,0 +1,32 @@ +import azure.functions as func +from azure.functions._http_asgi import AsgiResponse, AsgiRequest + +import nest_asyncio + +from app.main import app as ipam + +IS_INITED = False + +nest_asyncio.apply() + +async def run_setup(app): + """Workaround to run Starlette startup events on Azure Function Workers.""" + global IS_INITED + + if not IS_INITED: + await app.router.startup() + IS_INITED = True + + +async def handle_asgi_request(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: + asgi_request = AsgiRequest(req, context) + scope = asgi_request.to_asgi_http_scope() + asgi_response = await AsgiResponse.from_app(ipam, scope, req.get_body()) + + return asgi_response.to_func_response() + + +async def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: + await run_setup(ipam) + + return await handle_asgi_request(req, context) diff --git a/engine/ipam-func/function.json b/engine/ipam-func/function.json new file mode 100644 index 0000000..01051a4 --- /dev/null +++ b/engine/ipam-func/function.json @@ -0,0 +1,24 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post", + "patch", + "put", + "delete" + ], + "route": "/{*route}" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/engine/requirements.txt b/engine/requirements.txt index 6cc1b8f..e1c36a9 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -18,3 +18,5 @@ azure-mgmt-resource azure-mgmt-resourcegraph azure-keyvault-secrets azure-cosmos==4.3.0 +azure-functions +nest_asyncio diff --git a/ui/src/features/admin/admin2.js b/ui/src/features/admin/admin2.js new file mode 100644 index 0000000..8c9b375 --- /dev/null +++ b/ui/src/features/admin/admin2.js @@ -0,0 +1,397 @@ +import * as React from "react"; +import { styled } from '@mui/material/styles'; + +import { useMsal } from "@azure/msal-react"; +import { InteractionRequiredAuthError, InteractionStatus } from "@azure/msal-browser"; +import { callMsGraphUsersFilter } from "../../msal/graph"; + +import { useSnackbar } from 'notistack'; + +import { isEqual, throttle } from 'lodash'; + +import { + DataGrid, + GridOverlay, + GridToolbarContainer +} from "@mui/x-data-grid"; + +import { + Box, + Typography, + Tooltip, + IconButton, + Autocomplete, + TextField, + LinearProgress, + CircularProgress, + Popper, +} from "@mui/material"; + +import { + SaveAlt, + HighlightOff +} from "@mui/icons-material"; + +import Shrug from "../../img/pam/Shrug"; + +import { + getAdmins, + replaceAdmins +} from "../ipam/ipamAPI"; + +import { apiRequest } from "../../msal/authConfig"; + +function CustomToolbar(props) { + const { admins, loadedAdmins, setAdmins, selectionModel, refresh, refreshing } = props; + + const { instance, inProgress, accounts } = useMsal(); + const { enqueueSnackbar } = useSnackbar(); + + const [open, setOpen] = React.useState(false); + const [options, setOptions] = React.useState(null); + const [input, setInput] = React.useState(""); + const [selected, setSelected] = React.useState(null); + const [sending, setSending] = React.useState(false); + + const loading = open && !options + const unchanged = isEqual(admins, loadedAdmins); + + function SearchUsers(nameFilter) { + const request = { + scopes: ["Directory.Read.All"], + account: accounts[0], + }; + + (async () => { + try { + setOptions(null); + const response = await instance.acquireTokenSilent(request); + const userData = await callMsGraphUsersFilter(response.accessToken, nameFilter); + setOptions(userData.value); + } catch (e) { + if (e instanceof InteractionRequiredAuthError) { + instance.acquireTokenRedirect(request); + } else { + console.log("ERROR"); + console.log("------------------"); + console.log(e); + console.log("------------------"); + enqueueSnackbar(e.response.data.error, { variant: "error" }); + } + } + })(); + } + + const fetchUsers = React.useMemo(() => throttle((input) => SearchUsers(input), 500), []); + + React.useEffect(() => { + let active = true; + + if (active) { + fetchUsers(input); + } + + return () => { + active = false; + }; + }, [input, fetchUsers]); + + React.useEffect(() => { + if (!open) { + setOptions(null); + } + }, [input, open]); + + function handleAdd(user) { + let newAdmin = { + name: user.displayName, + id: user.id, + email: user.userPrincipalName, + }; + + if(!admins.find(obj => { return obj.id === user.id })) { + setAdmins((admins) => [...admins, newAdmin]); + } else { + console.log("Admin already added!"); + enqueueSnackbar('Admin already added!', { variant: 'error' }); + } + + setSelected(null); + } + + function onSave() { + const request = { + scopes: apiRequest.scopes, + account: accounts[0], + }; + + (async () => { + try { + setSending(true); + const response = await instance.acquireTokenSilent(request); + const data = await replaceAdmins(response.accessToken, admins); + enqueueSnackbar("Successfully updated admins", { variant: "success" }); + refresh(); + } catch (e) { + if (e instanceof InteractionRequiredAuthError) { + instance.acquireTokenRedirect(request); + } else { + console.log("ERROR"); + console.log("------------------"); + console.log(e); + console.log("------------------"); + enqueueSnackbar(e.response.data.error, { variant: "error" }); + } + } finally { + setSending(false); + } + })(); + } + + const popperStyle = { + popper: { + width: "fit-content" + } + }; + + const MyPopper = function (props) { + return ; + }; + + return ( + + + + { + setOpen(true); + }} + onClose={() => { + setOpen(false); + }} + onInputChange={(event, newInput) => { + setInput(newInput); + }} + onChange={(event, newValue) => { + newValue ? handleAdd(newValue) : setSelected(null); + }} + isOptionEqualToValue={(option, value) => option.displayName === value.displayName} + getOptionLabel={(option) => `${option.displayName} (${option.userPrincipalName})`} + options={options || []} + loading={loading} + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + + + + IPAM Admins + + + + + + + + + + + + ); +} + +export default function Administration() { + const { instance, accounts } = useMsal(); + const { enqueueSnackbar } = useSnackbar(); + + const [admins, setAdmins] = React.useState([]); + const [loadedAdmins, setLoadedAdmins] = React.useState([]); + const [selectionModel, setSelectionModel] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + const columns = [ + { field: "name", headerName: "Name", flex: 0.5 }, + { field: "email", headerName: "Email", flex: 1 }, + { field: "id", headerName: "Object ID", flex: 0.75 }, + { field: "", headerName: "", headerAlign: "center", align: "center", width: 25, sortable: false, renderCell: renderDelete } + ]; + + React.useEffect(() => { + refreshData(); + }, []); + + function refreshData() { + console.log("REFRESHING..."); + const request = { + scopes: apiRequest.scopes, + account: accounts[0], + }; + + (async () => { + try { + setLoading(true); + const response = await instance.acquireTokenSilent(request); + const data = await getAdmins(response.accessToken); + setAdmins(data); + setLoadedAdmins(data); + } catch (e) { + if (e instanceof InteractionRequiredAuthError) { + instance.acquireTokenRedirect(request); + } else { + console.log("ERROR"); + console.log("------------------"); + console.log(e); + console.log("------------------"); + enqueueSnackbar("Error fetching admins", { variant: "error" }); + } + } finally { + setLoading(false); + } + })(); + } + + function renderDelete(params) { + const flexCenter = { + display: "flex", + alignItems: "center", + justifyContent: "center" + } + + return ( + + + setAdmins(admins.filter(x => x.id !== params.row.id))} + > + + + + + ); + } + + function onModelChange(newModel) { + if(JSON.stringify(newModel) === JSON.stringify(selectionModel)) { + setSelectionModel([]); + } else { + setSelectionModel(newModel); + } + } + + const StyledGridOverlay = styled('div')({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }); + + function CustomNoRowsOverlay() { + return ( + + + + Nothing yet... + + + ); + } + + function CustomLoadingOverlay() { + return ( + +
+ +
+
+ ); + } + + return ( + // + // + + onModelChange(newSelectionModel)} + setSelectionModel={selectionModel} + components={{ + Toolbar: CustomToolbar, + NoRowsOverlay: CustomNoRowsOverlay, + LoadingOverlay: CustomLoadingOverlay, + }} + componentsProps={{ + toolbar: { + admins: admins, + loadedAdmins: loadedAdmins, + setAdmins, setAdmins, + selectionModel: selectionModel, + refresh: refreshData, + refreshing: loading + } + }} + sx={{ + "&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus, &.MuiDataGrid-root .MuiDataGrid-cell:focus, &.MuiDataGrid-root .MuiDataGrid-cell:focus-within": + { + outline: "none", + }, + }} + /> + + // + // + ); +} diff --git a/ui/src/features/drawer/drawer.js b/ui/src/features/drawer/drawer.js index 72272ca..30ea457 100644 --- a/ui/src/features/drawer/drawer.js +++ b/ui/src/features/drawer/drawer.js @@ -62,14 +62,17 @@ import Configure from "../../img/Configure"; import Admin from "../../img/Admin"; import Visualize from "../../img/Visualize"; import Conflict from "../../img/Conflict"; +import Person from "../../img/Person"; +import Rule from "../../img/Rule"; import UserSettings from "./userSettings"; import Welcome from "../welcome/Welcome"; import DiscoverTabs from "../tabs/discoverTabs"; import AnalyzeTabs from "../tabs/analyzeTabs"; +import AdminTabs from "../tabs/adminTabs"; // import AnalysisTool from "../analysis/analysis"; -import Administration from "../admin/admin"; +import Administration from "../admin/admin2"; import ConfigureIPAM from "../configure/configure"; import Refresh from "./refresh"; @@ -149,6 +152,7 @@ export default function NavDrawer() { { title: "Discover", icon: Discover, + admin: false, children: [ { title: "Spaces", @@ -185,6 +189,7 @@ export default function NavDrawer() { { title: "Analysis", icon: Analysis, + admin: false, children: [ { title: "Visualize", @@ -208,11 +213,30 @@ export default function NavDrawer() { link: "configure", admin: false }, + // { + // title: "Admin", + // icon: Admin, + // link: "admin", + // admin: true + // }, { title: "Admin", icon: Admin, - link: "admin", - admin: true + admin: true, + children: [ + { + title: "Admins", + icon: Person, + link: "admin/admins", + admin: true + }, + { + title: "Subscriptions", + icon: Rule, + link: "admin/subscriptions", + admin: true + } + ] }, ] ]; @@ -282,7 +306,8 @@ export default function NavDrawer() { {navItem.map((item, itemIndex) => { return item.hasOwnProperty('children') - ? + ? ((item.admin && isAdmin) || !item.admin) && + } /> } /> } /> - } /> + {/* } /> */} + } /> + } /> } /> diff --git a/ui/src/features/exclusions/exclusions.js b/ui/src/features/exclusions/exclusions.js new file mode 100644 index 0000000..59d2568 --- /dev/null +++ b/ui/src/features/exclusions/exclusions.js @@ -0,0 +1,297 @@ +import * as React from "react"; +import { styled } from "@mui/material/styles"; + +import { useSnackbar } from "notistack"; + +import { useMsal } from "@azure/msal-react"; +import { InteractionRequiredAuthError } from "@azure/msal-browser"; + +import { isEqual } from 'lodash'; + +import { DataGrid, GridOverlay } from "@mui/x-data-grid"; + +import { + Box, + Typography, + LinearProgress, + Tooltip, + IconButton +} from "@mui/material"; + +import { + SaveAlt +} from "@mui/icons-material"; + +import { fetchSubscriptions } from "../ipam/ipamAPI"; + +import { apiRequest } from "../../msal/authConfig"; + +// Page Styles + +const Wrapper = styled("div")(({ theme }) => ({ + display: "flex", + flexGrow: 1, + height: "calc(100vh - 160px)" +})); + +const MainBody = styled("div")({ + display: "flex", + height: "100%", + width: "100%", + flexDirection: "column", +}); + +const FloatingHeader = styled("div")(({ theme }) => ({ + ...theme.typography.h6, + display: "flex", + flexDirection: "row", + height: "7%", + width: "100%", + border: "1px solid rgba(224, 224, 224, 1)", + borderRadius: "4px", + marginBottom: theme.spacing(3) +})); + +const HeaderTitle = styled("div")(({ theme }) => ({ + ...theme.typography.h6, + width: "80%", + textAlign: "center", + alignSelf: "center", +})); + +const TopSection = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + height: "50%", + width: "100%", + border: "1px solid rgba(224, 224, 224, 1)", + borderRadius: "4px", + marginBottom: theme.spacing(1.5) +})); + +const BottomSection = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + height: "50%", + width: "100%", + border: "1px solid rgba(224, 224, 224, 1)", + borderRadius: "4px", + marginTop: theme.spacing(1.5) +})); + +// Grid Styles + +const GridHeader = styled("div")({ + height: "50px", + width: "100%", + display: "flex", + borderBottom: "1px solid rgba(224, 224, 224, 1)", +}); + +const GridTitle = styled("div")(({ theme }) => ({ + ...theme.typography.subtitle1, + width: "80%", + textAlign: "center", + alignSelf: "center", +})); + +const GridBody = styled("div")({ + height: "100%", + width: "100%", +}); + +const StyledGridOverlay = styled("div")({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", +}); + +function GridSection(props) { + const { title, action, columns, rows, loading, onClick } = props; + + function CustomLoadingOverlay() { + return ( + +
+ +
+
+ ); + } + + function CustomNoRowsOverlay() { + return ( + + + No Subscriptions Selected + + + ); + } + + const message = `Click to ${action}`; + + return ( + + + + {title} + + + + + onClick(rowData.row)} + loading={loading} + components={{ + LoadingOverlay: CustomLoadingOverlay, + NoRowsOverlay: CustomNoRowsOverlay, + }} + initialState={{ + sorting: { + sortModel: [{ field: 'name', sort: 'asc' }], + }, + }} + sx={{ + "&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus, &.MuiDataGrid-root .MuiDataGrid-cell:focus": + { + outline: "none", + }, + border: "none", + }} + /> + + + + ); +} + +const columns = [ + { field: "subscription_id", headerName: "Subscription ID", headerAlign: "left", align: "left", flex: 1 }, + { field: "name", headerName: "Subscription Name", headerAlign: "left", align: "left", flex: 2 }, + { field: "type", headerName: "Subscription Type", headerAlign: "left", align: "left", flex: 0.75 }, +]; + +export default function ManageExclusions() { + const { instance, inProgress, accounts } = useMsal(); + const { enqueueSnackbar } = useSnackbar(); + + const [loading, setLoading] = React.useState(false); + const [included, setIncluded] = React.useState([]); + const [excluded, setExcluded] = React.useState([]); + const [loadedExclusions, setLoadedExclusions] = React.useState([]); + const [sending, setSending] = React.useState(false); + + const unchanged = isEqual(excluded, loadedExclusions); + + React.useEffect(() => { + const request = { + scopes: apiRequest.scopes, + account: accounts[0], + }; + + (async () => { + try { + setLoading(true); + const response = await instance.acquireTokenSilent(request); + const data = await fetchSubscriptions(response.accessToken); + + setIncluded(data); + } catch (e) { + if (e instanceof InteractionRequiredAuthError) { + instance.acquireTokenRedirect(request); + } else { + console.log("ERROR"); + console.log("------------------"); + console.log(e); + console.log("------------------"); + enqueueSnackbar("Error fetching Subscriptions", { variant: "error" }); + } + } finally { + setLoading(false); + } + })(); + }, []); + + function subscriptionExclude(elem) { + const newArr = included.filter(object => { + return object.id !== elem.id; + }); + + setIncluded(newArr); + + setExcluded(excluded => [...excluded, elem]); + } + + function subscriptionInclude(elem) { + const newArr = excluded.filter(object => { + return object.id !== elem.id; + }); + + setExcluded(newArr); + + setIncluded(included => [...included, elem]); + } + + return ( + + + + + Subscription Management + + + console.log("CLICK")} + > + + + + + + + ({...x, renderCell: renderExclude}))} + columns={columns} + rows={included} + loading={loading} + onClick={subscriptionExclude} + /> + + + ({...x, renderCell: renderInclude}))} + columns={columns} + rows={excluded} + loading={false} + onClick={subscriptionInclude} + /> + + + + ); +} diff --git a/ui/src/features/ipam/ipamAPI.js b/ui/src/features/ipam/ipamAPI.js index c0ed3ff..97a4f7f 100644 --- a/ui/src/features/ipam/ipamAPI.js +++ b/ui/src/features/ipam/ipamAPI.js @@ -235,6 +235,23 @@ export function deleteBlockResvs(token, space, block, body) { }); } +export function fetchSubscriptions(token) { + var url = new URL(`${ENGINE_URL}/api/azure/subscription`); + + return axios + .get(url, { + headers: { + Authorization: `Bearer ${token}` + } + }) + .then(response => response.data) + .catch(error => { + console.log("ERROR FETCHING SUBSCRIPTIONS FROM API"); + console.log(error); + throw error; + }); +} + export function fetchVNets(token) { var url = new URL(`${ENGINE_URL}/api/azure/vnet`); diff --git a/ui/src/features/tabs/adminTabs.js b/ui/src/features/tabs/adminTabs.js new file mode 100644 index 0000000..dc37c2d --- /dev/null +++ b/ui/src/features/tabs/adminTabs.js @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { Link, useLocation } from "react-router-dom"; + +import PropTypes from 'prop-types'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import Box from '@mui/material/Box'; + +import Administration from '../admin/admin2'; +import ManageExclusions from '../exclusions/exclusions'; + +function TabPanel(props) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +TabPanel.propTypes = { + children: PropTypes.node, + index: PropTypes.number.isRequired, + value: PropTypes.number.isRequired, +}; + +function a11yProps(index) { + return { + id: `simple-tab-${index}`, + 'aria-controls': `simple-tabpanel-${index}`, + }; +} + +export default function AdminTabs() { + const allTabs = ['/admin/admins', '/admin/subscriptions']; + + let location = useLocation(); + + return ( + + + + + + + + + + + + + ); +} diff --git a/ui/src/img/Account.js b/ui/src/img/Account.js index 965e2a8..eee5652 100644 --- a/ui/src/img/Account.js +++ b/ui/src/img/Account.js @@ -8,8 +8,9 @@ function Account() { height="24" viewBox="0 0 24 24" > - - + + + ); } diff --git a/ui/src/img/Admin.js b/ui/src/img/Admin.js index a60ad45..f4ad10a 100644 --- a/ui/src/img/Admin.js +++ b/ui/src/img/Admin.js @@ -9,8 +9,11 @@ function Admin() { viewBox="0 0 24 24" > - - + + + + + ); } diff --git a/ui/src/img/Configure.js b/ui/src/img/Configure.js index 62900d8..1bb62d2 100644 --- a/ui/src/img/Configure.js +++ b/ui/src/img/Configure.js @@ -8,8 +8,8 @@ function Configure() { height="24" viewBox="0 0 24 24" > - - + + ); } diff --git a/ui/src/img/Exclude.js b/ui/src/img/Exclude.js new file mode 100644 index 0000000..281f926 --- /dev/null +++ b/ui/src/img/Exclude.js @@ -0,0 +1,17 @@ +import React from "react"; + +function Exclude() { + return ( + + + + + ); +} + +export default Exclude; diff --git a/ui/src/img/Home.js b/ui/src/img/Home.js index b937e7f..232b928 100644 --- a/ui/src/img/Home.js +++ b/ui/src/img/Home.js @@ -8,8 +8,8 @@ function Home() { height="24" viewBox="0 0 24 24" > - - + + ); } diff --git a/ui/src/img/Person.js b/ui/src/img/Person.js new file mode 100644 index 0000000..8c3a936 --- /dev/null +++ b/ui/src/img/Person.js @@ -0,0 +1,17 @@ +import React from "react"; + +function Person() { + return ( + + + + + ); +} + +export default Person; diff --git a/ui/src/img/Rule.js b/ui/src/img/Rule.js new file mode 100644 index 0000000..ed111a7 --- /dev/null +++ b/ui/src/img/Rule.js @@ -0,0 +1,17 @@ +import React from "react"; + +function Rule() { + return ( + + + + + ); +} + +export default Rule; diff --git a/ui/src/img/account.svg b/ui/src/img/account.svg index 11302b6..858c764 100644 --- a/ui/src/img/account.svg +++ b/ui/src/img/account.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ui/src/img/admin.svg b/ui/src/img/admin.svg index b47637e..f990551 100644 --- a/ui/src/img/admin.svg +++ b/ui/src/img/admin.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ui/src/img/configure.svg b/ui/src/img/configure.svg index 2ed12a8..27d3c04 100644 --- a/ui/src/img/configure.svg +++ b/ui/src/img/configure.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ui/src/img/exclude.svg b/ui/src/img/exclude.svg new file mode 100644 index 0000000..1bbb881 --- /dev/null +++ b/ui/src/img/exclude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/img/home.svg b/ui/src/img/home.svg index 19f6372..b3bbd60 100644 --- a/ui/src/img/home.svg +++ b/ui/src/img/home.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ui/src/img/rule.svg b/ui/src/img/rule.svg new file mode 100644 index 0000000..6e8233a --- /dev/null +++ b/ui/src/img/rule.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/msal/graph.js b/ui/src/msal/graph.js index 4b93d4e..32f3b2c 100644 --- a/ui/src/msal/graph.js +++ b/ui/src/msal/graph.js @@ -35,24 +35,52 @@ export async function callMsGraphUsers(accessToken) { .catch((error) => console.log(error)); } -export async function callMsGraphUsersFilter(accessToken, search) { - const headers = new Headers(); - const bearer = `Bearer ${accessToken}`; +export async function callMsGraphUsersFilter(accessToken, nameFilter = "") { + const headers = new Headers(); + const bearer = `Bearer ${accessToken}`; - headers.append("Authorization", bearer); + var endpoint = graphConfig.graphUsersEndpoint + "?"; - const options = { - method: "GET", - headers: headers, - }; + headers.append("Authorization", bearer); + headers.append("ConsistencyLevel", "eventual"); - let filter = `?$filter=startsWith(userPrincipalName,'${search}') OR startsWith(displayName, '${search}')` + const options = { + method: "GET", + headers: headers, + }; - return fetch((graphConfig.graphUsersEndpoint + filter), options) - .then((response) => response.json()) - .catch((error) => console.log(error)); + if (nameFilter != "") { + endpoint += `$filter=startsWith(userPrincipalName,'${nameFilter}') OR startsWith(displayName, '${nameFilter}')&`; + } + + endpoint += "$orderby=displayName&$count=true"; + + return fetch(endpoint, options) + .then((response) => response.json()) + .catch((error) => console.log(error)); } +// export async function callMsGraphUsersFilter(accessToken, search) { +// const headers = new Headers(); +// const bearer = `Bearer ${accessToken}`; + +// headers.append("Authorization", bearer); +// headers.append("ConsistencyLevel", "eventual"); + +// const options = { +// method: "GET", +// headers: headers, +// }; + +// let filter = `?$filter=startsWith(userPrincipalName,'${search}') OR startsWith(displayName, '${search}')` + +// let sort = "&$orderby=displayName&$count=true"; + +// return fetch((graphConfig.graphUsersEndpoint + filter + sort), options) +// .then((response) => response.json()) +// .catch((error) => console.log(error)); +// } + export async function callMsGraphPhoto(accessToken) { const headers = new Headers(); const bearer = `Bearer ${accessToken}`;