Merge branch 'multi-tenant' of github-msft:Azure/ipam into multi-tenant

This commit is contained in:
Harvey Bendana 2022-08-08 14:38:23 -07:00
Родитель c36eab59e1 8b40cb1d38
Коммит 4896f80ac4
44 изменённых файлов: 2209 добавлений и 975 удалений

Просмотреть файл

@ -64,6 +64,13 @@ param(
[switch]
$NoConsent,
[Parameter(Mandatory = $false,
ParameterSetName = 'Full')]
[Parameter(Mandatory = $false,
ParameterSetName = 'TemplateOnly')]
[switch]
$AsFunction,
[Parameter(Mandatory = $false,
ParameterSetName = 'Full')]
[Parameter(Mandatory = $false,
@ -193,7 +200,7 @@ Function Deploy-IPAMApplications {
Value = "access_as_user"
}
)
# PreAuthorizedApplication = @(
# PreAuthorizedApplication = @( # Allow Azure PowerShell/CLI to obtain access tokens
# @{
# AppId = "1950a258-227b-4e31-a9cf-717495945fc2" # Azure PowerShell
# DelegatedPermissionId = @( $engineApiGuid )
@ -433,6 +440,8 @@ Function Deploy-Bicep {
[Parameter(Mandatory=$false)]
[string]$NamePrefix,
[Parameter(Mandatory=$false)]
[boolean]$AsFunction,
[Parameter(Mandatory=$false)]
[hashtable]$Tags
)
@ -450,6 +459,10 @@ Function Deploy-Bicep {
$deploymentParameters.Add('namePrefix', $NamePrefix)
}
if($AsFunction) {
$deploymentParameters.Add('deployAsFunc', $AsFunction)
}
if($Tags) {
$deploymentParameters.Add('tags', $Tags)
}
@ -535,6 +548,7 @@ try {
if ($PSCmdlet.ParameterSetName -in ('Full', 'TemplateOnly')) {
$deployment = Deploy-Bicep @appDetails `
-NamePrefix $NamePrefix `
-AsFunction $AsFunction `
-Tags $Tags
}

118
deploy/functionApp.bicep Normal file
Просмотреть файл

@ -0,0 +1,118 @@
@description('Function App Name')
param functionAppName string
@description('Function App Plan Name')
param functionAppPlanName string
@description('CosmosDB URI')
param cosmosDbUri string
@description('KeyVault URI')
param keyVaultUri string
@description('Deployment Location')
param location string = resourceGroup().location
@description('Managed Identity Id')
param managedIdentityId string
@description('Storage Account Name')
param storageAccountName string
resource storageAccount 'Microsoft.Storage/storageAccounts@2021-06-01' existing = {
name: storageAccountName
}
resource functionAppPlan 'Microsoft.Web/serverfarms@2021-02-01' = {
name: functionAppPlanName
location: location
sku: {
name: 'EP1'
size: 'EP1'
tier: 'ElasticPremium'
capacity: 1
}
kind: 'linux'
properties: {
reserved: true
}
}
resource functionApp 'Microsoft.Web/sites@2021-03-01' = {
name: functionAppName
location: location
kind: 'functionapp,linux,container'
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${managedIdentityId}': {}
}
}
properties: {
httpsOnly: true
serverFarmId: functionAppPlan.id
keyVaultReferenceIdentity: managedIdentityId
siteConfig: {
alwaysOn: true
linuxFxVersion: 'azureipam.azurecr.io/ipam-func:latest'
appSettings: [
{
name: 'COSMOS_URL'
value: cosmosDbUri
}
{
name: 'COSMOS_KEY'
value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/COSMOS-KEY/)'
}
{
name: 'CLIENT_ID'
value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)'
}
{
name: 'CLIENT_SECRET'
value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)'
}
{
name: 'TENANT_ID'
value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)'
}
{
name: 'KEYVAULT_URL'
value: keyVaultUri
}
{
name: 'AzureWebJobsStorage'
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
}
{
name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
}
{
name: 'WEBSITE_CONTENTSHARE'
value: toLower(functionAppName)
}
{
name: 'FUNCTIONS_EXTENSION_VERSION'
value: '~4'
}
{
name: 'APPINSIGHTS_INSTRUMENTATIONKEY'
value: applicationInsights.properties.InstrumentationKey
}
]
}
}
}
resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
name: functionAppName
location: location
kind: 'web'
properties: {
Application_Type: 'web'
Request_Source: 'rest'
}
}
output functionAppHostName string = functionApp.properties.defaultHostName

Просмотреть файл

@ -10,6 +10,9 @@ param location string = deployment().location
@description('Prefix for Resource Naming')
param namePrefix string = 'ipam'
@description('Flag to Deploy IPAM as a Function')
param deployAsFunc bool = false
@description('IPAM-UI App Registration Client/App ID')
param uiAppId string
@ -51,7 +54,7 @@ module managedIdentity 'managedIdentity.bicep' = {
}
// KeyVault for Secure Values
module keyVault 'keyVault.bicep' ={
module keyVault 'keyVault.bicep' = {
name: 'keyVaultModule'
scope: resourceGroup
params: {
@ -84,11 +87,12 @@ module storageAccount 'storageAccount.bicep' = {
storageAccountName: storageName
principalId: managedIdentity.outputs.principalId
managedIdentityId: managedIdentity.outputs.id
deployAsFunc: deployAsFunc
}
}
// App Service w/ Docker Compose + CI
module appService 'appService.bicep' = {
module appService 'appService.bicep' = if (!deployAsFunc) {
scope: resourceGroup
name: 'appServiceModule'
params: {
@ -103,5 +107,20 @@ module appService 'appService.bicep' = {
}
}
// Function App
module functionApp 'functionApp.bicep' = if (deployAsFunc) {
scope: resourceGroup
name: 'functionAppModule'
params: {
location: location
functionAppPlanName: appServicePlanName
functionAppName: appServiceName
keyVaultUri: keyVault.outputs.keyVaultUri
cosmosDbUri: cosmos.outputs.cosmosDocumentEndpoint
managedIdentityId: managedIdentity.outputs.id
storageAccountName: storageAccount.outputs.name
}
}
// Outputs
output appServiceHostName string = appService.outputs.appServiceHostName

Просмотреть файл

@ -16,6 +16,9 @@ param roleAssignmentName string = newGuid()
@description('Storage Account Name')
param storageAccountName string
@description('Flag to Deploy IPAM as a Function')
param deployAsFunc bool
var storageBlobDataContributor = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'
var storageBlobDataContributorId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataContributor)
@ -32,11 +35,11 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2021-06-01' = {
}
}
resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2021-06-01' = {
resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2021-06-01' = if (!deployAsFunc) {
name: '${storageAccount.name}/default/${containerName}'
}
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = if (!deployAsFunc) {
name: roleAssignmentName
scope: blobContainer
properties: {
@ -46,7 +49,7 @@ resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-prev
}
}
resource copyNginxConfig 'Microsoft.Resources/deploymentScripts@2020-10-01' = {
resource copyNginxConfig 'Microsoft.Resources/deploymentScripts@2020-10-01' = if (!deployAsFunc) {
name: 'copyNginxConfig'
location: location
kind: 'AzurePowerShell'

Просмотреть файл

@ -0,0 +1 @@
local.settings.json

50
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

11
engine/Dockerfile.func Normal file
Просмотреть файл

@ -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-appservice
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
AzureFunctionsJobHost__Logging__Console__IsEnabled=true
COPY requirements.txt /
RUN pip install -r /requirements.txt
COPY . /home/site/wwwroot

Просмотреть файл

@ -1,9 +1,8 @@
from fastapi import Request, HTTPException
from azure.cosmos.aio import CosmosClient
import jwt
import time
import copy
from app.routers.common.helper import (
cosmos_query
@ -26,13 +25,20 @@ async def check_token_expired(request: Request):
if(now >= int(decoded['exp'])):
raise HTTPException(status_code=401, detail="Token has expired.")
await check_admin(request, decoded['oid'])
request.state.tenant_id = decoded['tid']
async def check_admin(request: Request, user_oid: str):
item = await cosmos_query("admins")
await check_admin(request, decoded['oid'], decoded['tid'])
if item['admins']:
is_admin = next((x for x in item['admins'] if user_oid == x['id']), None)
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 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
@ -40,3 +46,6 @@ async def check_admin(request: Request, user_oid: str):
async def get_admin(request: Request):
return request.state.admin
async def get_tenant_id(request: Request):
return request.state.tenant_id

Просмотреть файл

@ -1,6 +1,40 @@
CLIENT_ID=""
CLIENT_SECRET=""
TENANT_ID=""
COSMOS_URL=""
COSMOS_KEY=""
KEYVAULT_URL=""
import os
class Globals:
@property
def CLIENT_ID(self):
return os.environ.get('CLIENT_ID')
@property
def CLIENT_SECRET(self):
return os.environ.get('CLIENT_SECRET')
@property
def TENANT_ID(self):
return os.environ.get('TENANT_ID')
@property
def COSMOS_URL(self):
return os.environ.get('COSMOS_URL')
@property
def COSMOS_KEY(self):
return os.environ.get('COSMOS_KEY')
@property
def KEYVAULT_URL(self):
return os.environ.get('KEYVAULT_URL')
@property
def DATABASE_NAME(self):
db_name = os.environ.get('DATABASE_NAME')
return db_name if db_name else 'ipam-db'
@property
def CONTAINER_NAME(self):
ctr_name = os.environ.get('CONTAINER_NAME')
return ctr_name if ctr_name else 'ipam-ctr'
globals = Globals()

Просмотреть файл

@ -1,22 +1,27 @@
from audioop import tostereo
from fastapi import FastAPI, Request, HTTPException, Header
from fastapi.responses import JSONResponse, RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import HTTPException as StarletteHTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi_restful.tasks import repeat_every
from fastapi.encoders import jsonable_encoder
from azure.cosmos.aio import CosmosClient
from azure.cosmos import PartitionKey
from azure.cosmos.exceptions import CosmosResourceExistsError
from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError
from app.routers import azure, admin, user, space
import os
import uuid
import logging
from pathlib import Path
import app.globals as globals
from app.globals import globals
from app.routers.common.helper import (
cosmos_upsert
)
BUILD_DIR = os.path.join(os.getcwd(), "app", "build")
@ -119,18 +124,85 @@ if os.path.isdir(BUILD_DIR):
return FileResponse(BUILD_DIR + "/index.html")
async def db_upgrade():
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:
spaces_query = await container.read_item("spaces", partition_key="spaces")
for space in spaces_query['spaces']:
if 'vnets' in space:
del space['vnets']
new_space = {
"id": uuid.uuid4(),
"type": "space",
"tenant_id": globals.TENANT_ID,
**space
}
await cosmos_upsert(jsonable_encoder(new_space))
await container.delete_item("spaces", partition_key = "spaces")
logger.info('Spaces database conversion complete!')
except CosmosResourceNotFoundError:
logger.info('No existing spaces to convert...')
pass
try:
users_query = await container.read_item("users", partition_key="users")
for user in users_query['users']:
new_user = {
"id": uuid.uuid4(),
"type": "user",
"tenant_id": globals.TENANT_ID,
"data": user
}
await cosmos_upsert(jsonable_encoder(new_user))
await container.delete_item("users", partition_key = "users")
logger.info('Users database conversion complete!')
except CosmosResourceNotFoundError:
logger.info('No existing users to convert...')
pass
try:
admins_query = await container.read_item("admins", partition_key="admins")
admin_data = {
"id": uuid.uuid4(),
"type": "admin",
"tenant_id": globals.TENANT_ID,
"admins": admins_query['admins'],
"exclusions": []
}
await cosmos_upsert(jsonable_encoder(admin_data))
await container.delete_item("admins", partition_key = "admins")
logger.info('Admins database conversion complete!')
except CosmosResourceNotFoundError:
logger.info('No existing admins to convert...')
pass
await cosmos_client.close()
@app.on_event("startup")
async def set_globals():
globals.CLIENT_ID = os.environ.get('CLIENT_ID')
globals.CLIENT_SECRET = os.environ.get('CLIENT_SECRET')
globals.TENANT_ID = os.environ.get('TENANT_ID')
globals.COSMOS_URL = os.environ.get('COSMOS_URL')
globals.COSMOS_KEY = os.environ.get('COSMOS_KEY')
globals.KEYVAULT_URL = os.environ.get('KEYVAULT_URL')
client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY)
database_name = 'ipam-db'
database_name = globals.DATABASE_NAME
try:
logger.info('Creating Database...')
@ -141,37 +213,13 @@ async def set_globals():
logger.warning('Database exists! Using existing database...')
database = client.get_database_client(database_name)
container_name = 'ipam-container'
container_name = globals.CONTAINER_NAME
try:
logger.info('Creating Container...')
container = await database.create_container(
id = container_name,
partition_key = PartitionKey(path = "/id")
)
logger.info('Creating Spaces Item...')
await container.upsert_item(
{
'id': 'spaces',
'spaces': []
}
)
logger.info('Creating Admins Item...')
await container.upsert_item(
{
'id': 'admins',
'admins': []
}
)
logger.info('Creating Users Item...')
await container.upsert_item(
{
'id': 'users',
'users': []
}
partition_key = PartitionKey(path = "/tenant_id")
)
except CosmosResourceExistsError:
logger.warning('Container exists! Using existing container...')
@ -179,11 +227,14 @@ async def set_globals():
await client.close()
await db_upgrade()
# https://github.com/yuval9313/FastApi-RESTful/issues/138
@app.on_event("startup")
@repeat_every(seconds = 60, wait_first = True) # , wait_first=True
async def find_reservations() -> None:
await azure.match_resv_to_vnets()
if not os.environ.get("FUNCTIONS_WORKER_RUNTIME"):
await azure.match_resv_to_vnets()
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):

Просмотреть файл

@ -1,9 +1,9 @@
from pydantic import BaseModel, ValidationError, EmailStr
from pydantic import BaseModel, ValidationError, EmailStr, root_validator
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 #
######################
@ -231,6 +166,13 @@ class Reservation(BaseModel):
userId: str
createdOn: float
status: str
tag: Optional[dict]
@root_validator
def format_tag(cls, values) -> dict:
values["tag"] = { "X-IPAM-RES-ID": values["id"]}
return values
class BlockBasic(BaseModel):
"""DOCSTRING"""
@ -333,3 +275,51 @@ class SpaceExpandUtil(BaseModel):
blocks: List[BlockExpandUtil]
size: int
used: int
####################
# ADMIN MODELS #
####################
class Admin(BaseModel):
"""DOCSTRING"""
name: str
email: EmailStr
id: UUID
class Config:
json_encoders = {
UUID: lambda v: str(v),
}
class Subscription(UUID):
"""DOCSTRING"""
class Exclusions(List[UUID]):
"""DOCSTRING"""
###################
# 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"""

Просмотреть файл

@ -3,41 +3,59 @@ 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 . import argquery
from app.routers.common.helper import (
cosmos_query,
cosmos_upsert
cosmos_upsert,
cosmos_replace,
cosmos_delete,
cosmos_retry,
arg_query
)
router = APIRouter(
prefix="/admins",
tags=["admins"],
prefix="/admin",
tags=["admin"],
dependencies=[Depends(check_token_expired)]
)
class Admin(BaseModel):
name: str
email: EmailStr
id: UUID
async def new_admin_db(admin_list, exclusion_list, tenant_id):
admin_data = {
"id": uuid.uuid4(),
"type": "admin",
"tenant_id": tenant_id,
"admins": admin_list,
"exclusions": exclusion_list
}
class Config:
json_encoders = {
UUID: lambda v: str(v),
}
query_results = await cosmos_upsert(jsonable_encoder(admin_data))
return query_results
@router.get(
"",
summary = "Get All Admins"
"/admins",
summary = "Get All Admins",
response_model = List[Admin],
status_code = 200
)
async def get_admins(
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
@ -47,17 +65,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(
"",
"/admins",
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,84 +96,39 @@ 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:
await new_admin_db([admin], [], tenant_id)
else:
admin_data = copy.deepcopy(admin_query[0])
if target_admin:
raise HTTPException(status_code=400, detail="User is already an admin.")
target_admin = next((x for x in admin_data['admins'] if x['id'] == admin.id), None)
item['admins'].append(jsonable_encoder(admin))
if target_admin:
raise HTTPException(status_code=400, detail="User is already an admin.")
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
admin_data['admins'].append(jsonable_encoder(admin))
await cosmos_replace(admin_query[0], admin_data)
return Response(status_code=status.HTTP_201_CREATED)
@router.delete(
"/{objectId}",
summary = "Delete IPAM Admin",
status_code=200
)
async def delete_admin(
objectId: UUID,
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_index = next((i for i, admin in enumerate(item['admins']) if admin['id'] == str(objectId)), None)
if not admin_index:
raise HTTPException(status_code=400, detail="Invalid admin objectId.")
del item['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
return Response(status_code=status.HTTP_200_OK)
@router.put(
"",
"/admins",
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 +140,213 @@ 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.")
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.")
admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id)
if not admin_query:
await new_admin_db(admin_list, [], tenant_id)
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)
@router.delete(
"/admins/{objectId}",
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
"""
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)
id_list = [x.id for x in admin_list]
unique_admins = len(set(id_list)) == len(admin_list)
if admin_query is None:
raise HTTPException(status_code=400, detail="Admin not found.")
if not unique_admins:
raise HTTPException(status_code=400, detail="List contains one or more duplicate objectId's.")
admin_data = copy.deepcopy(admin_query[0])
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
admin_index = next((i for i, admin in enumerate(admin_data['admins']) if admin['id'] == str(objectId)), None)
return PlainTextResponse(status_code=status.HTTP_200_OK)
if admin_index is None:
raise HTTPException(status_code=400, detail="Invalid admin objectId.")
del admin_data['admins'][admin_index]
await cosmos_replace(admin_query[0], admin_data)
return Response(status_code=status.HTTP_200_OK)
@router.get(
"/exclusions",
summary = "Get Excluded Subscriptions",
response_model = List[Subscription],
status_code = 200
)
async def get_exclusions(
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Get a list of excluded subscriptions.
"""
if not is_admin:
raise HTTPException(status_code=403, detail="API restricted to admins.")
admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id)
if admin_query:
admin_data = copy.deepcopy(admin_query[0])
return admin_data['exclusions']
else:
return []
@router.post(
"/exclusions",
summary = "Add Excluded Subscription(s)",
status_code=200
)
@cosmos_retry(
max_retry = 5,
error_msg = "Error adding exclusion(s), please try again."
)
async def add_exclusions(
exclusions: List[Subscription],
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Add a list of excluded subscriptions:
- **[&lt;UUID&gt;]**: Array of Subscription ID's
"""
if not is_admin:
raise HTTPException(status_code=403, detail="API restricted to admins.")
subscription_list = await arg_query(None, True, argquery.SUBSCRIPTION)
invalid_subscriptions = [str(x) for x in exclusions if str(x) not in [y['subscription_id'] for y in subscription_list]]
if invalid_subscriptions:
print(invalid_subscriptions)
raise HTTPException(status_code=400, detail="One or more invalid subscriptions id's provided.")
admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id)
if not admin_query:
await new_admin_db([], list(set(exclusions)), tenant_id)
else:
admin_data = copy.deepcopy(admin_query[0])
admin_data['exclusions'] = jsonable_encoder(list(set(admin_data['exclusions'] + exclusions)))
await cosmos_replace(admin_query[0], admin_data)
return Response(status_code=status.HTTP_200_OK)
@router.put(
"/exclusions",
summary = "Replace Excluded Subscriptions",
status_code=200
)
@cosmos_retry(
max_retry = 5,
error_msg = "Error updating exclusions, please try again."
)
async def update_exclusions(
exclusions: List[Subscription],
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Replace the list of excluded subscriptions:
- **[&lt;UUID&gt;]**: Array of Subscription ID's
"""
if not is_admin:
raise HTTPException(status_code=403, detail="API restricted to admins.")
subscription_list = await arg_query(None, True, argquery.SUBSCRIPTION)
invalid_subscriptions = [str(x) for x in exclusions if str(x) not in [y['subscription_id'] for y in subscription_list]]
if invalid_subscriptions:
raise HTTPException(status_code=400, detail="One or more invalid subscriptions id's provided.")
admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id)
if not admin_query:
await new_admin_db([], list(set(exclusions)), tenant_id)
else:
admin_data = copy.deepcopy(admin_query[0])
admin_data['exclusions'] = jsonable_encoder(list(set(exclusions)))
await cosmos_replace(admin_query[0], admin_data)
return Response(status_code=status.HTTP_200_OK)
@router.delete(
"/exclusions/{subscriptionId}",
summary = "Remove Excluded Subscription",
status_code=200
)
@cosmos_retry(
max_retry = 5,
error_msg = "Error removing exclusion, please try again."
)
async def remove_exclusion(
subscriptionId: Subscription,
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Remove an excluded subscription id
"""
if not is_admin:
raise HTTPException(status_code=403, detail="API restricted to admins.")
admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id)
if not admin_query:
raise HTTPException(status_code=400, detail="Subscription id not found.")
admin_data = copy.deepcopy(admin_query[0])
exclusion_index = next((i for i, exclusion in enumerate(admin_data['exclusions']) if exclusion == str(subscriptionId)), None)
if exclusion_index is None:
raise HTTPException(status_code=400, detail="Invalid subscription id.")
del admin_data['exclusions'][exclusion_index]
await cosmos_replace(admin_query[0], admin_data)
return Response(status_code=status.HTTP_200_OK)

Просмотреть файл

@ -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 = """
@ -41,7 +47,8 @@ resources
VNET="""
resources
| where type =~ 'Microsoft.Network/virtualNetworks'
| project name, id, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId, prefixes = properties.addressSpace.addressPrefixes, resv = tostring(tags["ipam-res-id"])
| where subscriptionId !in~ {}
| project name, id, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId, prefixes = properties.addressSpace.addressPrefixes, resv = tostring(coalesce(tags['X-IPAM-RES-ID'], tags['ipam-res-id']))
| join kind = leftouter(
resources
| where type =~ 'Microsoft.Network/virtualNetworks'
@ -57,14 +64,16 @@ resources
SUBNET = """
resources
| where type =~ 'Microsoft.Network/virtualNetworks'
| where subscriptionId !in~ {}
| mv-expand subnet = todynamic(properties.subnets)
| extend subnet_size = array_length(subnet.properties.ipConfigurations)
| project name = subnet.name, id = subnet.id, prefix = subnet.properties.addressPrefix,resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId,vnet_name = name, vnet_id = id, used = (iif(isnull(subnet_size), 0, subnet_size) + 5), appgw_config = subnet.properties.applicationGatewayIPConfigurations
| project name = subnet.name, id = subnet.id, prefix = subnet.properties.addressPrefix, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId,vnet_name = name, vnet_id = id, used = (iif(isnull(subnet_size), 0, subnet_size) + 5), appgw_config = subnet.properties.applicationGatewayIPConfigurations
"""
PRIVATE_ENDPOINT = """
resources
| where type =~ 'microsoft.network/networkinterfaces'
| where subscriptionId !in~ {}
| where isnotempty(properties.privateEndpoint)
| mv-expand ipconfig = properties.ipConfigurations
| project pe_id = tostring(properties.privateEndpoint.id), subnet_id = tolower(tostring(ipconfig.properties.subnet.id)), group_id = ipconfig.properties.privateLinkConnectionProperties.groupId, private_ip = ipconfig.properties.privateIPAddress
@ -89,6 +98,7 @@ resources
VIRTUAL_MACHINE = """
resources
| where type =~ 'microsoft.compute/virtualmachines'
| where subscriptionId !in~ {}
| extend nics = array_length(properties.networkProfile.networkInterfaces)
| mv-expand nic = properties.networkProfile.networkInterfaces
| project tenant_id = tenantId, id = id, name = name, size = properties.hardwareProfile.vmSize, resource_group = resourceGroup, subscription_id = subscriptionId, nic_id = nic.id
@ -122,6 +132,7 @@ resources
VM_SCALE_SET = """
ComputeResources
| where type =~ "microsoft.compute/virtualmachinescalesets/virtualmachines"
| where subscriptionId !in~ {}
| project name, tostring(id), resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId
| join kind = leftouter (
ComputeResources
@ -146,6 +157,7 @@ ComputeResources
FIREWALL_VNET = """
resources
| where type =~ 'Microsoft.Network/azureFirewalls'
| where subscriptionId !in~ {}
| where properties.sku.name =~ 'AZFW_VNet'
| extend ipConfigs = array_length(properties.ipConfigurations)
| mv-expand ipConfig = properties.ipConfigurations
@ -173,6 +185,7 @@ resources
FIREWALL_VHUB = """
resources
| where type =~ 'Microsoft.Network/azureFirewalls'
| where subscriptionId !in~ {}
| where properties.sku.name =~ 'AZFW_Hub'
| project name = name, id = id, size = properties.sku.tier, resource_group = resourceGroup, subscription_id = subscriptionId, private_ip_address = properties.hubIPAddresses.privateIPAddress, virtual_hub_id = properties.virtualHub.id, tenant_id = tenantId
| join kind = leftouter (
@ -188,6 +201,7 @@ resources
BASTION = """
resources
| where type =~ 'Microsoft.Network/bastionHosts'
| where subscriptionId !in~ {}
| extend ipConfigs = array_length(properties.ipConfigurations)
| mv-expand ipConfig = properties.ipConfigurations
| project tenant_id = tenantId, id = id, name = name, size = sku.name, resource_group = resourceGroup, subscription_id = subscriptionId, private_ip = dynamic(null), private_ip_alloc_method = ipConfig.properties.privateIPAllocationMethod, subnet_id = ipConfig.properties.subnet.id, public_ip_id = ipConfig.properties.publicIPAddress.id
@ -214,17 +228,18 @@ resources
APP_GATEWAY = """
resources
| where type =~ 'Microsoft.Network/applicationGateways'
| where subscriptionId !in~ {}
| mv-expand ipConfig = properties.frontendIPConfigurations
| where isnotempty(ipConfig.properties.publicIPAddress)
| project tenant_id = tenantId, name, id, size = properties.sku.tier, resource_group = resourceGroup, subscription_id = subscriptionId, public_ip_id = ipConfig.properties.publicIPAddress.id, public_ip_alloc_method = ipConfig.properties.privateIPAllocationMethod
| extend public_ip_id = tostring(public_ip_id)
| where isnotempty(ipConfig.properties.privateIPAddress)
| project name, tenant_id = tenantId, id, size = properties.sku.tier, resource_group = resourceGroup, subscription_id = subscriptionId, private_ip = ipConfig.properties.privateIPAddress, private_ip_alloc_method = ipConfig.properties.privateIPAllocationMethod, subnet_id = ipConfig.properties.subnet.id
| extend subnet_id = tolower(tostring(subnet_id))
| join kind = leftouter (
resources
| where type =~ 'Microsoft.Network/applicationGateways'
| mv-expand ipConfig = properties.frontendIPConfigurations
| where isnotempty(ipConfig.properties.privateIPAddress)
| project name, private_ip = ipConfig.properties.privateIPAddress, private_ip_alloc_method = ipConfig.properties.privateIPAllocationMethod, subnet_id = ipConfig.properties.subnet.id
| extend subnet_id = tolower(tostring(subnet_id))
| where isnotempty(ipConfig.properties.publicIPAddress)
| project name, public_ip_id = ipConfig.properties.publicIPAddress.id, public_ip_alloc_method = ipConfig.properties.privateIPAllocationMethod
| extend public_ip_id = tostring(public_ip_id)
) on name
| project-away name1
| join kind = leftouter (
@ -249,6 +264,7 @@ resources
APIM = """
resources
| where type =~ 'Microsoft.ApiManagement/service'
| where subscriptionId !in~ {}
| where properties.provisioningState =~ 'Succeeded'
| mv-expand privateIP = properties.privateIPAddresses, publicIP = properties.publicIPAddresses
| project name, id, resource_group = resourceGroup, subscription_id = subscriptionId, private_ip = privateIP, public_ip = publicIP, subnet_id = properties.virtualNetworkConfiguration.subnetResourceId, tenant_id = tenantId, vnet_type = properties.virtualNetworkType

Просмотреть файл

@ -11,13 +11,21 @@ from azure.mgmt.resource.subscriptions.aio import SubscriptionClient
import azure.cosmos.exceptions as exceptions
import re
import copy
import asyncio
import logging
from ipaddress import IPv4Network
from netaddr import IPSet, IPNetwork
from uuid import uuid4
from app.dependencies import check_token_expired, get_admin
from sqlalchemy import true
from app.dependencies import (
check_token_expired,
get_admin,
get_tenant_id
)
from . import argquery
from app.routers.common.helper import (
@ -25,6 +33,8 @@ from app.routers.common.helper import (
get_obo_credentials,
cosmos_query,
cosmos_upsert,
cosmos_replace,
cosmos_retry,
arg_query
)
@ -32,6 +42,8 @@ from app.routers.space import (
get_spaces
)
from app.globals import globals
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
console = logging.StreamHandler()
@ -181,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.
@ -197,18 +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)
authorization: str = Header(None),
tenant_id: str = Depends(get_tenant_id),
admin: str = Depends(get_admin)
):
"""
Get a list of Azure Virtual Networks.
"""
item = await cosmos_query("spaces")
space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space'", tenant_id)
vnet_list = await arg_query(authorization, admin, argquery.VNET)
@ -230,24 +243,24 @@ async def get_vnet(
vnet['used'] = total_used
# Python 3.9+
# ip_blocks = [(block | {'parentSpace': space['name']}) for space in item['spaces'] for block in space['blocks']]
ip_blocks = [{**block , **{'parentSpace': space['name']}} for space in item['spaces'] for block in space['blocks']]
# ip_blocks = [(block | {'parentSpace': space['name']}) for space in space_query for block in space['blocks']]
ip_blocks = [{**block , **{'parentSpace': space['name']}} for space in space_query for block in space['blocks']]
ip_block = next((x for x in ip_blocks if vnet['id'] in [v['id'] for v in x['vnets']]), None)
vnet['parentSpace'] = ip_block['parentSpace'] if ip_block else None
vnet['parentBlock'] = ip_block['name'] if ip_block else None
updated_vnet_list.append(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.
@ -303,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.
@ -319,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
@ -335,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.
@ -352,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.
@ -368,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.
@ -384,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.
@ -400,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.
@ -416,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.
@ -438,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.
@ -465,12 +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)
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.
@ -482,8 +496,8 @@ async def multi(
subnet_list = []
endpoint_list = []
tasks.append(asyncio.create_task(multi_helper(get_spaces, space_list, False, True, authorization, True)))
tasks.append(asyncio.create_task(multi_helper(get_vnet, vnet_list, authorization, admin)))
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, 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)))
@ -577,76 +591,79 @@ async def multi(
return tree
@cosmos_retry(
max_retry = 5,
error_msg = "Error updating reservation status!"
)
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)
current_try = 0
max_retry = 5
space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space'", globals.TENANT_ID)
while True:
try:
query = await cosmos_query("spaces")
for space in space_query:
original_space = copy.deepcopy(space)
for space in query['spaces']:
for block in space['blocks']:
for vnet in block['vnets']:
active = next((x for x in vnet_list if x['id'] == vnet['id']), None)
for block in space['blocks']:
for vnet in block['vnets']:
active = next((x for x in vnet_list if x['id'] == vnet['id']), None)
if active:
vnet['active'] = True
else:
vnet['active'] = False
if active:
vnet['active'] = True
else:
vnet['active'] = False
for index, resv in enumerate(block['resv']):
if resv['id'] in stale_resv:
vnet = next((x for x in vnet_list if x['resv'] == resv['id']), None)
for index, resv in enumerate(block['resv']):
if resv['id'] in stale_resv:
vnet = next((x for x in vnet_list if x['resv'] == resv['id']), None)
# print("RESV: {}".format(vnet['resv']))
# print("BLOCK {}".format(block['name']))
# print("VNET {}".format(vnet['id']))
# print("INDEX: {}".format(index))
# print("RESV: {}".format(vnet['resv']))
# print("BLOCK {}".format(block['name']))
# print("VNET {}".format(vnet['id']))
# print("INDEX: {}".format(index))
stale_resv.remove(resv['id'])
resv['status'] = "wait"
stale_resv.remove(resv['id'])
resv['status'] = "wait"
cidr_match = resv['cidr'] in vnet['prefixes']
cidr_match = resv['cidr'] in vnet['prefixes']
if not cidr_match:
# print("Reservation ID assigned to vNET which does not have an address space that matches the reservation.")
resv['status'] = "warnCIDRMismatch"
if not cidr_match:
# print("Reservation ID assigned to vNET which does not have an address space that matches the reservation.")
# logging.info("Reservation ID assigned to vNET which does not have an address space that matches the reservation.")
resv['status'] = "warnCIDRMismatch"
existing_block_cidrs = []
existing_block_cidrs = []
for v in block['vnets']:
target_vnet = next((x for x in vnet_list if x['id'].lower() == v['id'].lower()), None)
for v in block['vnets']:
target_vnet = next((x for x in vnet_list if x['id'].lower() == v['id'].lower()), None)
if target_vnet:
target_cidr = next((x for x in target_vnet['prefixes'] if IPNetwork(x) in IPNetwork(block['cidr'])), None)
existing_block_cidrs.append(target_cidr)
if target_vnet:
target_cidr = next((x for x in target_vnet['prefixes'] if IPNetwork(x) in IPNetwork(block['cidr'])), None)
existing_block_cidrs.append(target_cidr)
vnet_cidr = next((x for x in vnet['prefixes'] if IPNetwork(x) in IPNetwork(block['cidr'])), None)
vnet_cidr = next((x for x in vnet['prefixes'] if IPNetwork(x) in IPNetwork(block['cidr'])), None)
if vnet_cidr in existing_block_cidrs:
# print("A vNET with the assigned CIDR has already been associated with the target IP Block.")
resv['status'] = "errCIDRExists"
if vnet_cidr in existing_block_cidrs:
# print("A vNET with the assigned CIDR has already been associated with the target IP Block.")
# logging.info("A vNET with the assigned CIDR has already been associated with the target IP Block.")
resv['status'] = "errCIDRExists"
if resv['status'] == "wait":
# print("vNET association complete, adding vNET to Block.")
block['vnets'].append({
"id": vnet['id'],
"active": True
})
del block['resv'][index]
await cosmos_upsert("spaces", query)
except exceptions.CosmosAccessConditionFailedError:
if current_try < max_retry:
current_try += 1
continue
else:
print("Error updating reservation status!")
else:
break
if resv['status'] == "wait":
# print("vNET is being added to IP Block...")
# logging.info("vNET is being added to IP Block...")
block['vnets'].append(
{
"id": vnet['id'],
"active": True
}
)
del block['resv'][index]
else:
# print("Resetting status to 'wait'.")
# logging.info("Resetting status to 'wait'.")
resv['status'] = "wait"
await cosmos_replace(original_space, space)
# print("STALE:")
# print(stale_resv)

Просмотреть файл

@ -12,8 +12,11 @@ import azure.cosmos.exceptions as exceptions
import os
import jwt
from functools import wraps
import app.globals as globals
from requests import options
from app.globals import globals
SCOPE = "https://management.azure.com/user_impersonation"
@ -61,57 +64,148 @@ async def get_obo_credentials(assertion):
return credential
async def cosmos_query(target: str):
async def cosmos_query(query: str, tenant_id: str):
"""DOCSTRING"""
cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY)
database_name = "ipam-db"
database_name = globals.DATABASE_NAME
database = cosmos_client.get_database_client(database_name)
container_name = "ipam-container"
container_name = globals.CONTAINER_NAME
container = database.get_container_client(container_name)
item = await container.read_item(target, partition_key=target)
query_results = container.query_items(
query = query,
# enable_cross_partition_query=True,
partition_key = tenant_id
)
result_array = [result async for result in query_results]
await cosmos_client.close()
return item
return result_array
async def cosmos_upsert(target: str, data):
async def cosmos_upsert(data):
"""DOCSTRING"""
cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY)
database_name = "ipam-db"
database_name = globals.DATABASE_NAME
database = cosmos_client.get_database_client(database_name)
container_name = "ipam-container"
container_name = globals.CONTAINER_NAME
container = database.get_container_client(container_name)
try:
await container.upsert_item(
data,
match_condition=MatchConditions.IfNotModified,
etag=data['_etag']
res = await container.upsert_item(data)
except:
raise
finally:
await cosmos_client.close()
await cosmos_client.close()
return res
async def cosmos_replace(old, new):
"""DOCSTRING"""
cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY)
database_name = globals.DATABASE_NAME
database = cosmos_client.get_database_client(database_name)
container_name = globals.CONTAINER_NAME
container = database.get_container_client(container_name)
try:
await container.replace_item(
item = old,
body = new,
match_condition = MatchConditions.IfNotModified,
etag = old['_etag']
)
except:
raise
finally:
await cosmos_client.close()
await cosmos_client.close()
return
async def cosmos_delete(item, tenant_id: str):
"""DOCSTRING"""
cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY)
database_name = globals.DATABASE_NAME
database = cosmos_client.get_database_client(database_name)
container_name = globals.CONTAINER_NAME
container = database.get_container_client(container_name)
try:
await container.delete_item(
item = item,
partition_key = tenant_id
)
except:
raise
finally:
await cosmos_client.close()
await cosmos_client.close()
return
def cosmos_retry(error_msg, max_retry = 5):
"""DOCSTRING"""
def cosmos_retry_decorator(func):
@wraps(func)
async def func_with_retries(*args, **kwargs):
_tries = max_retry
while _tries > 0:
try:
return await func(*args, **kwargs)
except exceptions.CosmosAccessConditionFailedError:
_tries -= 1
if _tries == 0:
raise HTTPException(status_code=500, detail=error_msg)
return func_with_retries
return cosmos_retry_decorator
async def arg_query(auth, admin, query):
"""DOCSTRING"""
if admin:
creds = await get_client_credentials()
tenant_id = globals.TENANT_ID
else:
user_assertion=auth.split(' ')[1]
creds = await get_obo_credentials(user_assertion)
tenant_id = get_tenant_from_jwt(user_assertion)
exclusions_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id)
if exclusions_query:
exclusions_array = exclusions_query[0]['exclusions']
if exclusions_array:
exclusions = "(" + str(exclusions_array)[1:-1] + ")"
else:
exclusions = "('')"
else:
exclusions = "('')"
try:
results = await arg_query_helper(creds, query)
results = await arg_query_helper(creds, query.format(exclusions))
except ClientAuthenticationError:
raise HTTPException(status_code=401, detail="Token has expired.")
except HttpResponseError as e:
@ -156,21 +250,34 @@ 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)
except ServiceRequestError:
skip_token = None
while True:
query_request = 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_request)
results = results + poll.data
if poll.skip_token:
skip_token = poll.skip_token
else:
break
except ServiceRequestError as e:
print(e)
raise HTTPException(status_code=500, detail="Error communicating with Azure.")
finally:
await resource_graph_client.close()
return poll.data
return results

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -8,17 +8,31 @@ 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.admin import (
new_admin_db
)
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 +41,20 @@ router = APIRouter(
dependencies=[Depends(check_token_expired)]
)
class User(BaseModel):
"""DOCSTRING"""
id: UUID
apiRefresh: int
isAdmin: bool
class Config:
json_encoders = {
UUID: lambda v: str(v),
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
}
}
class JSONPatch(BaseModel):
"""DOCSTRING"""
query_results = await cosmos_upsert(jsonable_encoder(new_user))
op: str
path: str
value: Any
class UserUpdate(List[JSONPatch]):
"""DOCSTRING"""
return query_results
async def scrub_patch(patch):
scrubbed_patch = []
@ -79,6 +86,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 +98,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 +121,43 @@ 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)
if not admin_query:
admin_query = [await new_admin_db([], [], tenant_id)]
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 +165,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 +188,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']

20
engine/host.json Normal file
Просмотреть файл

@ -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)"
}
}

Просмотреть файл

@ -0,0 +1,30 @@
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)

Просмотреть файл

@ -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"
}
]
}

Просмотреть файл

@ -0,0 +1,16 @@
import datetime
import logging
import azure.functions as func
from app.routers.azure import match_resv_to_vnets
async def main(mytimer: func.TimerRequest) -> None:
utc_timestamp = datetime.datetime.utcnow().replace(
tzinfo=datetime.timezone.utc).isoformat()
if mytimer.past_due:
logging.info('The timer is past due!')
logging.info('Python timer trigger function ran at %s', utc_timestamp)
await match_resv_to_vnets()

Просмотреть файл

@ -0,0 +1,11 @@
{
"scriptFile": "__init__.py",
"bindings": [
{
"name": "mytimer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 * * * * *"
}
]
}

Просмотреть файл

@ -18,3 +18,5 @@ azure-mgmt-resource
azure-mgmt-resourcegraph
azure-keyvault-secrets
azure-cosmos==4.3.0
azure-functions
nest_asyncio

Просмотреть файл

@ -3,11 +3,11 @@ import { styled } from '@mui/material/styles';
import { useMsal } from "@azure/msal-react";
import { InteractionRequiredAuthError, InteractionStatus } from "@azure/msal-browser";
import { callMsGraphUsers } from "../../msal/graph";
import { callMsGraphUsersFilter } from "../../msal/graph";
import { useSnackbar } from 'notistack';
import { isEqual } from 'lodash';
import { isEqual, throttle } from 'lodash';
import {
DataGrid,
@ -42,71 +42,65 @@ import {
import { apiRequest } from "../../msal/authConfig";
function CustomToolbar(props) {
const { admins, loadedAdmins, setAdmins, selectionModel, refresh } = 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 selectedAdmin = admins.find(obj => { return obj.id === selectionModel[0] });
const changed = isEqual(admins, loadedAdmins);
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;
function SearchUsers() {
const request = {
scopes: ["Directory.Read.All"],
account: accounts[0],
};
if (!options && inProgress === InteractionStatus.None) {
(async () => {
try {
const response = await instance.acquireTokenSilent(request);
const userData = await callMsGraphUsers(response.accessToken);
setOptions(userData.value);
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" });
}
}
})();
}
if (active) {
fetchUsers(input);
}
if (!loading) {
return undefined;
}
(async () => {
if (active) {
SearchUsers();
}
})();
return () => {
active = false;
};
}, [loading, accounts, instance]);
}, [input, fetchUsers]);
React.useEffect(() => {
if (!open) {
setOptions(null);
}
}, [open]);
}, [input, open]);
function handleAdd(user) {
let newAdmin = {
@ -191,6 +185,9 @@ function CustomToolbar(props) {
onClose={() => {
setOpen(false);
}}
onInputChange={(event, newInput) => {
setInput(newInput);
}}
onChange={(event, newValue) => {
newValue ? handleAdd(newValue) : setSelected(null);
}}
@ -228,9 +225,9 @@ function CustomToolbar(props) {
aria-label="upload picture"
component="span"
style={{
visibility: changed ? 'hidden' : 'visible',
disabled: sending
visibility: unchanged ? 'hidden' : 'visible'
}}
disabled={sending || refreshing}
onClick={onSave}
>
<SaveAlt />
@ -263,6 +260,7 @@ export default function Administration() {
}, []);
function refreshData() {
console.log("REFRESHING...");
const request = {
scopes: apiRequest.scopes,
account: accounts[0],
@ -291,23 +289,7 @@ export default function Administration() {
})();
}
function handleDelete(id) {
// const index = admins.findIndex(x => {
// return x.id === id;
// });
setAdmins(admins.filter(x => x.id !== id));
}
function renderDelete(params) {
const onClick = (e) => {
e.stopPropagation();
console.log("CLICK: " + params.row.id);
// setSelectionModel([params.value]);
// setRowData(params.row);
// setMenuExpand(true);
};
function renderDelete(params) {
const flexCenter = {
display: "flex",
alignItems: "center",
@ -326,7 +308,7 @@ export default function Administration() {
disableFocusRipple
disableTouchRipple
disableRipple
onClick={() => handleDelete(params.row.id)}
onClick={() => setAdmins(admins.filter(x => x.id !== params.row.id))}
>
<HighlightOff />
</IconButton>
@ -373,42 +355,39 @@ export default function Administration() {
}
return (
<Box sx={{ height: "calc(100vh - 64px)", width: "100%" }}>
<Box sx={{ p: 3, height: "calc(100vh - 112px)", display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<Box height="100%" width="100%">
<DataGrid
disableColumnMenu
hideFooter
hideFooterPagination
hideFooterSelectedRowCount
rows={admins}
columns={columns}
loading={loading}
onSelectionModelChange={(newSelectionModel) => onModelChange(newSelectionModel)}
setSelectionModel={selectionModel}
components={{
Toolbar: CustomToolbar,
NoRowsOverlay: CustomNoRowsOverlay,
LoadingOverlay: CustomLoadingOverlay,
}}
componentsProps={{
toolbar: {
admins: admins,
loadedAdmins: loadedAdmins,
setAdmins, setAdmins,
selectionModel: selectionModel,
refresh: refreshData
}
}}
sx={{
"&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus, &.MuiDataGrid-root .MuiDataGrid-cell:focus, &.MuiDataGrid-root .MuiDataGrid-cell:focus-within":
{
outline: "none",
},
}}
/>
</Box>
</Box>
<Box sx={{ flexGrow: 1, height: "100%" }}>
<DataGrid
disableColumnMenu
hideFooter
hideFooterPagination
hideFooterSelectedRowCount
rows={admins}
columns={columns}
loading={loading}
onSelectionModelChange={(newSelectionModel) => 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",
},
}}
/>
</Box>
);
}

Просмотреть файл

@ -251,6 +251,16 @@ export default function EditVnets(props) {
outline: "none",
}
}}
initialState={{
sorting: {
sortModel: [
{
field: 'name',
sort: 'asc',
},
],
},
}}
/>
</Box>
</DialogContent>

Просмотреть файл

@ -62,14 +62,16 @@ 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 ConfigureIPAM from "../configure/configure";
import Refresh from "./refresh";
@ -149,6 +151,7 @@ export default function NavDrawer() {
{
title: "Discover",
icon: Discover,
admin: false,
children: [
{
title: "Spaces",
@ -185,6 +188,7 @@ export default function NavDrawer() {
{
title: "Analysis",
icon: Analysis,
admin: false,
children: [
{
title: "Visualize",
@ -208,11 +212,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 +305,8 @@ export default function NavDrawer() {
<List>
{navItem.map((item, itemIndex) => {
return item.hasOwnProperty('children')
? <React.Fragment key={`item-${item.title}`}>
? ((item.admin && isAdmin) || !item.admin) &&
<React.Fragment key={`item-${item.title}`}>
<ListItem
key={item.title}
component="div"
@ -626,7 +650,9 @@ export default function NavDrawer() {
<Route path="analyze/visualize" element={<AnalyzeTabs />} />
<Route path="analyze/conflict" element={<AnalyzeTabs />} />
<Route path="configure" element={<ConfigureIPAM />} />
<Route path="admin" element={<Administration />} />
{/* <Route path="admin" element={<Administration />} /> */}
<Route path="admin/admins" element={<AdminTabs />} />
<Route path="admin/subscriptions" element={<AdminTabs />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Box>

Просмотреть файл

@ -15,6 +15,9 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
Switch,
Stack,
Typography
} from "@mui/material";
import {
@ -104,9 +107,19 @@ export default function UserSettings(props) {
Settings
</DialogTitle>
<DialogContent>
{/*
<DialogContentText>
IP Usage Format:
</DialogContentText>
<Box sx={{ display: "flex", flexDirection: "row", alignItems: "center", p: 1 }}>
<Typography>Standard</Typography>
<Switch inputProps={{ 'aria-label': 'ant design' }} />
<Typography>Azure</Typography>
</Box>
<DialogContentText>
Data refresh interval (minutes):
</DialogContentText>
*/}
<Box sx={{ p: 1 }}>
<Slider
aria-label="Restricted values"

Просмотреть файл

@ -0,0 +1,362 @@
import * as React from "react";
import { useDispatch } from 'react-redux';
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,
getExclusions,
replaceExclusions
} from "../ipam/ipamAPI";
import {
refreshAllAsync
} from '../ipam/ipamSlice';
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 (
<GridOverlay>
<div style={{ position: "absolute", top: 0, width: "100%" }}>
<LinearProgress />
</div>
</GridOverlay>
);
}
function CustomNoRowsOverlay() {
return (
<StyledGridOverlay>
<Typography variant="overline" display="block" sx={{ mt: 1 }}>
No Subscriptions Selected
</Typography>
</StyledGridOverlay>
);
}
const message = `Click to ${action}`;
return (
<React.Fragment>
<GridHeader
style={{
borderBottom: "1px solid rgba(224, 224, 224, 1)",
}}
>
<Box sx={{ width: "20%" }} />
<GridTitle>{title}</GridTitle>
<Box sx={{ width: "20%" }} />
</GridHeader>
<Tooltip title={message} followCursor>
<GridBody>
<DataGrid
disableColumnMenu
// disableSelectionOnClick
hideFooter
hideFooterPagination
hideFooterSelectedRowCount
density="compact"
rows={rows}
columns={columns}
onRowClick={(rowData) => 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",
}}
/>
</GridBody>
</Tooltip>
</React.Fragment>
);
}
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 dispatch = useDispatch();
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 stack = [
(async () => await fetchSubscriptions(response.accessToken))(),
(async () => await getExclusions(response.accessToken))()
];
Promise.all(stack).then((results) => {
var includedSubs = results[0];
var excludedSubs = [];
results[1].forEach(exclusion => {
includedSubs = includedSubs.filter(object => {
return object.subscription_id !== exclusion;
});
const excludeObj = results[0].find(element => element.subscription_id == exclusion);
excludedSubs = [...excludedSubs, excludeObj];
});
setIncluded(includedSubs);
setExcluded(excludedSubs);
setLoadedExclusions(excludedSubs);
setLoading(false);
});
} catch (e) {
if (e instanceof InteractionRequiredAuthError) {
instance.acquireTokenRedirect(request);
} else {
console.log("ERROR");
console.log("------------------");
console.log(e);
console.log("------------------");
enqueueSnackbar("Error fetching subscriptions/exclusions", { variant: "error" });
}
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]);
}
function onSave() {
const request = {
scopes: apiRequest.scopes,
account: accounts[0],
};
(async () => {
try {
setSending(true);
let update = excluded.map(item => item.subscription_id);
const response = await instance.acquireTokenSilent(request);
const data = await replaceExclusions(response.accessToken, update);
enqueueSnackbar("Successfully updated exclusions", { variant: "success" });
setLoadedExclusions(excluded);
dispatch(refreshAllAsync(response.accessToken))
} 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);
}
})();
}
return (
<Wrapper>
<MainBody>
<FloatingHeader>
<Box sx={{ width: "20%" }}></Box>
<HeaderTitle>Subscription Management</HeaderTitle>
<Box display="flex" justifyContent="flex-end" alignItems="center" sx={{ width: "20%", ml: 2, mr: 2 }}>
<Tooltip title="Save" >
<IconButton
color="primary"
aria-label="upload picture"
component="span"
style={{
visibility: unchanged ? 'hidden' : 'visible'
}}
disabled={sending}
onClick={onSave}
>
<SaveAlt />
</IconButton>
</Tooltip>
</Box>
</FloatingHeader>
<TopSection>
<GridSection
title="Included Subscriptions"
action="exclude"
// columns={columns.map((x) => ({...x, renderCell: renderExclude}))}
columns={columns}
rows={included}
loading={loading}
onClick={subscriptionExclude}
/>
</TopSection>
<BottomSection>
<GridSection
title="Excluded Subscriptions"
action="include"
// columns={columns.map((x) => ({...x, renderCell: renderInclude}))}
columns={columns}
rows={excluded}
loading={loading}
onClick={subscriptionInclude}
/>
</BottomSection>
</MainBody>
</Wrapper>
);
}

Просмотреть файл

@ -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`);
@ -341,7 +358,7 @@ export function fetchTreeView(token) {
}
export function getAdmins(token) {
const url = new URL(`${ENGINE_URL}/api/admins`);
const url = new URL(`${ENGINE_URL}/api/admin/admins`);
return axios
.get(url, {
@ -358,7 +375,7 @@ export function getAdmins(token) {
}
export function replaceAdmins(token, body) {
const url = new URL(`${ENGINE_URL}/api/admins`);
const url = new URL(`${ENGINE_URL}/api/admin/admins`);
return axios
.put(url, body, {
@ -374,6 +391,40 @@ export function replaceAdmins(token, body) {
});
}
export function getExclusions(token) {
const url = new URL(`${ENGINE_URL}/api/admin/exclusions`);
return axios
.get(url, {
headers: {
Authorization: `Bearer ${token}`
},
})
.then(response => response.data)
.catch(error => {
console.log("ERROR FETCHING EXCLUSIONS VIA API");
console.log(error);
throw error;
});
}
export function replaceExclusions(token, body) {
const url = new URL(`${ENGINE_URL}/api/admin/exclusions`);
return axios
.put(url, body, {
headers: {
Authorization: `Bearer ${token}`
},
})
.then(response => response.data)
.catch(error => {
console.log("ERROR UPDATING EXCLUSIONS VIA API");
console.log(error);
throw error;
});
}
export function getMe(token) {
const url = new URL(`${ENGINE_URL}/api/users/me`);

Просмотреть файл

@ -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/admin';
import ManageExclusions from '../exclusions/exclusions';
function TabPanel(props) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ p: 3, height: 'calc(100vh - 161px)' }}>
{children}
</Box>
)}
</div>
);
}
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 (
<Box sx={{ width: '100%', height: 'calc(100vh - 137px)'}}>
<React.Fragment>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={allTabs.indexOf(location.pathname)}>
<Tab label="Admins" component={Link} to={allTabs[0]} {...a11yProps(0)} />
<Tab label="Subscriptions" component={Link} to={allTabs[1]} {...a11yProps(1)} />
</Tabs>
</Box>
<TabPanel value={allTabs.indexOf(location.pathname)} index={0}><Administration /></TabPanel>
<TabPanel value={allTabs.indexOf(location.pathname)} index={1}><ManageExclusions /></TabPanel>
</React.Fragment>
</Box>
);
}

Просмотреть файл

@ -8,8 +8,9 @@ function Account() {
height="24"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z"></path>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2a7.2 7.2 0 01-6-3.22c.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08a7.2 7.2 0 01-6 3.22z"></path>
<path fill="none" d="M0 0H24V24H0z"></path>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM7.35 18.5C8.66 17.56 10.26 17 12 17s3.34.56 4.65 1.5c-1.31.94-2.91 1.5-4.65 1.5s-3.34-.56-4.65-1.5zm10.79-1.38a9.947 9.947 0 00-12.28 0A7.957 7.957 0 014 12c0-4.42 3.58-8 8-8s8 3.58 8 8c0 1.95-.7 3.73-1.86 5.12z"></path>
<path d="M12 6c-1.93 0-3.5 1.57-3.5 3.5S10.07 13 12 13s3.5-1.57 3.5-3.5S13.93 6 12 6zm0 5c-.83 0-1.5-.67-1.5-1.5S11.17 8 12 8s1.5.67 1.5 1.5S12.83 11 12 11z"></path>
</svg>
);
}

Просмотреть файл

@ -9,8 +9,11 @@ function Admin() {
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0H24V24H0z"></path>
<path d="M17 11c.34 0 .67.04 1 .09V6.27L10.5 3 3 6.27v4.91c0 4.54 3.2 8.79 7.5 9.82.55-.13 1.08-.32 1.6-.55-.69-.98-1.1-2.17-1.1-3.45 0-3.31 2.69-6 6-6z"></path>
<path d="M17 13c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 1.38c.62 0 1.12.51 1.12 1.12s-.51 1.12-1.12 1.12-1.12-.51-1.12-1.12.5-1.12 1.12-1.12zm0 5.37c-.93 0-1.74-.46-2.24-1.17.05-.72 1.51-1.08 2.24-1.08s2.19.36 2.24 1.08c-.5.71-1.31 1.17-2.24 1.17z"></path>
<g fillRule="evenodd">
<circle cx="17" cy="15.5" r="1.12"></circle>
<path d="M17 17.5c-.73 0-2.19.36-2.24 1.08.5.71 1.32 1.17 2.24 1.17s1.74-.46 2.24-1.17c-.05-.72-1.51-1.08-2.24-1.08z"></path>
<path d="M18 11.09V6.27L10.5 3 3 6.27v4.91c0 4.54 3.2 8.79 7.5 9.82.55-.13 1.08-.32 1.6-.55A5.973 5.973 0 0017 23c3.31 0 6-2.69 6-6 0-2.97-2.16-5.43-5-5.91zM11 17c0 .56.08 1.11.23 1.62-.24.11-.48.22-.73.3-3.17-1-5.5-4.24-5.5-7.74v-3.6l5.5-2.4 5.5 2.4v3.51c-2.84.48-5 2.94-5 5.91zm6 4c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"></path>
</g>
</svg>
);
}

Просмотреть файл

@ -8,8 +8,8 @@ function Configure() {
height="24"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z" clipRule="evenodd"></path>
<path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"></path>
<path fill="none" d="M0 0h24v24H0V0z"></path>
<path d="M22.61 18.99l-9.08-9.08c.93-2.34.45-5.1-1.44-7C9.79.61 6.21.4 3.66 2.26L7.5 6.11 6.08 7.52 2.25 3.69C.39 6.23.6 9.82 2.9 12.11c1.86 1.86 4.57 2.35 6.89 1.48l9.11 9.11c.39.39 1.02.39 1.41 0l2.3-2.3c.4-.38.4-1.01 0-1.41zm-3 1.6l-9.46-9.46c-.61.45-1.29.72-2 .82-1.36.2-2.79-.21-3.83-1.25C3.37 9.76 2.93 8.5 3 7.26l3.09 3.09 4.24-4.24-3.09-3.09c1.24-.07 2.49.37 3.44 1.31a4.469 4.469 0 011.24 3.96 4.35 4.35 0 01-.88 1.96l9.45 9.45-.88.89z"></path>
</svg>
);
}

17
ui/src/img/Exclude.js Normal file
Просмотреть файл

@ -0,0 +1,17 @@
import React from "react";
function Exclude() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0H24V24H0z"></path>
<path d="M14 10H3v2h11v-2zm0-4H3v2h11V6zM3 16h7v-2H3v2zm11.41 6L17 19.41 19.59 22 21 20.59 18.41 18 21 15.41 19.59 14 17 16.59 14.41 14 13 15.41 15.59 18 13 20.59 14.41 22z"></path>
</svg>
);
}
export default Exclude;

Просмотреть файл

@ -8,8 +8,8 @@ function Home() {
height="24"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z"></path>
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"></path>
<path fill="none" d="M0 0h24v24H0V0z"></path>
<path d="M12 5.69l5 4.5V18h-2v-6H9v6H7v-7.81l5-4.5M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3z"></path>
</svg>
);
}

17
ui/src/img/Person.js Normal file
Просмотреть файл

@ -0,0 +1,17 @@
import React from "react";
function Person() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0V0z"></path>
<path d="M4 18v-.65c0-.34.16-.66.41-.81C6.1 15.53 8.03 15 10 15c.03 0 .05 0 .08.01.1-.7.3-1.37.59-1.98-.22-.02-.44-.03-.67-.03-2.42 0-4.68.67-6.61 1.82-.88.52-1.39 1.5-1.39 2.53V20h9.26c-.42-.6-.75-1.28-.97-2H4zM10 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0-6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zM20.75 16c0-.22-.03-.42-.06-.63l1.14-1.01-1-1.73-1.45.49c-.32-.27-.68-.48-1.08-.63L18 11h-2l-.3 1.49c-.4.15-.76.36-1.08.63l-1.45-.49-1 1.73 1.14 1.01c-.03.21-.06.41-.06.63s.03.42.06.63l-1.14 1.01 1 1.73 1.45-.49c.32.27.68.48 1.08.63L16 21h2l.3-1.49c.4-.15.76-.36 1.08-.63l1.45.49 1-1.73-1.14-1.01c.03-.21.06-.41.06-.63zM17 18c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"></path>
</svg>
);
}
export default Person;

17
ui/src/img/Rule.js Normal file
Просмотреть файл

@ -0,0 +1,17 @@
import React from "react";
function Rule() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0H24V24H0z"></path>
<path d="M16.54 11L13 7.46l1.41-1.41 2.12 2.12 4.24-4.24 1.41 1.41L16.54 11zM11 7H2v2h9V7zm10 6.41L19.59 12 17 14.59 14.41 12 13 13.41 15.59 16 13 18.59 14.41 20 17 17.41 19.59 20 21 18.59 18.41 16 21 13.41zM11 15H2v2h9v-2z"></path>
</svg>
);
}
export default Rule;

Просмотреть файл

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M12,2C6.48,2,2,6.48,2,12s4.48,10,10,10s10-4.48,10-10S17.52,2,12,2z M7.35,18.5C8.66,17.56,10.26,17,12,17 s3.34,0.56,4.65,1.5C15.34,19.44,13.74,20,12,20S8.66,19.44,7.35,18.5z M18.14,17.12L18.14,17.12C16.45,15.8,14.32,15,12,15 s-4.45,0.8-6.14,2.12l0,0C4.7,15.73,4,13.95,4,12c0-4.42,3.58-8,8-8s8,3.58,8,8C20,13.95,19.3,15.73,18.14,17.12z"/><path d="M12,6c-1.93,0-3.5,1.57-3.5,3.5S10.07,13,12,13s3.5-1.57,3.5-3.5S13.93,6,12,6z M12,11c-0.83,0-1.5-0.67-1.5-1.5 S11.17,8,12,8s1.5,0.67,1.5,1.5S12.83,11,12,11z"/></g></g></svg>

До

Ширина:  |  Высота:  |  Размер: 365 B

После

Ширина:  |  Высота:  |  Размер: 698 B

Просмотреть файл

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M17,11c0.34,0,0.67,0.04,1,0.09V6.27L10.5,3L3,6.27v4.91c0,4.54,3.2,8.79,7.5,9.82c0.55-0.13,1.08-0.32,1.6-0.55 C11.41,19.47,11,18.28,11,17C11,13.69,13.69,11,17,11z"/><path d="M17,13c-2.21,0-4,1.79-4,4c0,2.21,1.79,4,4,4s4-1.79,4-4C21,14.79,19.21,13,17,13z M17,14.38c0.62,0,1.12,0.51,1.12,1.12 s-0.51,1.12-1.12,1.12s-1.12-0.51-1.12-1.12S16.38,14.38,17,14.38z M17,19.75c-0.93,0-1.74-0.46-2.24-1.17 c0.05-0.72,1.51-1.08,2.24-1.08s2.19,0.36,2.24,1.08C18.74,19.29,17.93,19.75,17,19.75z"/></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><g><circle cx="17" cy="15.5" fill-rule="evenodd" r="1.12"/><path d="M17,17.5c-0.73,0-2.19,0.36-2.24,1.08c0.5,0.71,1.32,1.17,2.24,1.17 s1.74-0.46,2.24-1.17C19.19,17.86,17.73,17.5,17,17.5z" fill-rule="evenodd"/><path d="M18,11.09V6.27L10.5,3L3,6.27v4.91c0,4.54,3.2,8.79,7.5,9.82 c0.55-0.13,1.08-0.32,1.6-0.55C13.18,21.99,14.97,23,17,23c3.31,0,6-2.69,6-6C23,14.03,20.84,11.57,18,11.09z M11,17 c0,0.56,0.08,1.11,0.23,1.62c-0.24,0.11-0.48,0.22-0.73,0.3c-3.17-1-5.5-4.24-5.5-7.74v-3.6l5.5-2.4l5.5,2.4v3.51 C13.16,11.57,11,14.03,11,17z M17,21c-2.21,0-4-1.79-4-4c0-2.21,1.79-4,4-4s4,1.79,4,4C21,19.21,19.21,21,17,21z" fill-rule="evenodd"/></g></g></svg>

До

Ширина:  |  Высота:  |  Размер: 675 B

После

Ширина:  |  Высота:  |  Размер: 814 B

Просмотреть файл

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path clip-rule="evenodd" d="M0 0h24v24H0z" fill="none"/><path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M22.61 18.99l-9.08-9.08c.93-2.34.45-5.1-1.44-7C9.79.61 6.21.4 3.66 2.26L7.5 6.11 6.08 7.52 2.25 3.69C.39 6.23.6 9.82 2.9 12.11c1.86 1.86 4.57 2.35 6.89 1.48l9.11 9.11c.39.39 1.02.39 1.41 0l2.3-2.3c.4-.38.4-1.01 0-1.41zm-3 1.6l-9.46-9.46c-.61.45-1.29.72-2 .82-1.36.2-2.79-.21-3.83-1.25C3.37 9.76 2.93 8.5 3 7.26l3.09 3.09 4.24-4.24-3.09-3.09c1.24-.07 2.49.37 3.44 1.31 1.08 1.08 1.49 2.57 1.24 3.96-.12.71-.42 1.37-.88 1.96l9.45 9.45-.88.89z"/></svg>

До

Ширина:  |  Высота:  |  Размер: 326 B

После

Ширина:  |  Высота:  |  Размер: 580 B

1
ui/src/img/exclude.svg Normal file
Просмотреть файл

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><path d="M14,10H3v2h11V10z M14,6H3v2h11V6z M3,16h7v-2H3V16z M14.41,22L17,19.41L19.59,22L21,20.59L18.41,18L21,15.41L19.59,14 L17,16.59L14.41,14L13,15.41L15.59,18L13,20.59L14.41,22z"/></g></svg>

После

Ширина:  |  Высота:  |  Размер: 361 B

Просмотреть файл

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 5.69l5 4.5V18h-2v-6H9v6H7v-7.81l5-4.5M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3z"/></svg>

До

Ширина:  |  Высота:  |  Размер: 173 B

После

Ширина:  |  Высота:  |  Размер: 217 B

1
ui/src/img/rule.svg Normal file
Просмотреть файл

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/><path d="M16.54,11L13,7.46l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L16.54,11z M11,7H2v2h9V7z M21,13.41L19.59,12L17,14.59 L14.41,12L13,13.41L15.59,16L13,18.59L14.41,20L17,17.41L19.59,20L21,18.59L18.41,16L21,13.41z M11,15H2v2h9V15z"/></g></svg>

После

Ширина:  |  Высота:  |  Размер: 403 B

Просмотреть файл

@ -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}`;