diff --git a/Policies.png b/Policies.png new file mode 100644 index 00000000..263b1e1f Binary files /dev/null and b/Policies.png differ diff --git a/preview.png b/preview.png new file mode 100644 index 00000000..e96e3c7b Binary files /dev/null and b/preview.png differ diff --git a/roadrecon/.dockerignore b/roadrecon/.dockerignore new file mode 100644 index 00000000..13c3b7dc --- /dev/null +++ b/roadrecon/.dockerignore @@ -0,0 +1 @@ +roadrecon.db \ No newline at end of file diff --git a/roadrecon/Dockerfile b/roadrecon/Dockerfile new file mode 100644 index 00000000..f417a1be --- /dev/null +++ b/roadrecon/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim AS dev +COPY . /app +WORKDIR /app +RUN pip install . +CMD [ "python3","/app/roadtools/roadrecon/server.py","--debug" ] + +FROM python:3.11-slim AS prod +COPY . /app +WORKDIR /app +RUN pip install . +CMD [ "python3","/app/roadtools/roadrecon/server.py" ] \ No newline at end of file diff --git a/roadrecon/docker-compose.yml b/roadrecon/docker-compose.yml new file mode 100644 index 00000000..14500b69 --- /dev/null +++ b/roadrecon/docker-compose.yml @@ -0,0 +1,21 @@ +services: + frontend: + build: + context: frontend-ng + target: ${TARGET:-prod} + ports: + - 5173:5173 + volumes: + - ./frontend-ng/:/usr/src/app/ + restart: always + depends_on: + - backend + backend: + build: + context: . + target: ${TARGET:-prod} + volumes: + - ./:/app/ + ports: + - 5000:5000 + restart: always diff --git a/roadrecon/frontend-ng/.gitignore b/roadrecon/frontend-ng/.gitignore new file mode 100644 index 00000000..53f7466a --- /dev/null +++ b/roadrecon/frontend-ng/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local \ No newline at end of file diff --git a/roadrecon/frontend-ng/CHANGELOG.md b/roadrecon/frontend-ng/CHANGELOG.md new file mode 100644 index 00000000..a4386b3c --- /dev/null +++ b/roadrecon/frontend-ng/CHANGELOG.md @@ -0,0 +1,57 @@ +# CHANGELOG.md + +## [3.0.0] - 2024-07-05 + +- Mosaic Redesign + +## [2.1.0] - 2023-12-08 + +Update to Vite 5 +Update dependencies + +## [2.0.1] - 2023-10-04 + +- Dependencies update + +## [2.0.0] - 2023-06-01 + +- Dark version added + +## [1.4.3] - 2023-04-11 + +- Update dependencies + +## [1.4.2] - 2023-02-13 + +- Update dependencies +- Improve sidebar icons color logic + +## [1.4.0] - 2022-08-30 + +- Update sidebar + +## [1.3.0] - 2022-07-15 + +- Replace Sass with CSS files +- Update dependencies + +## [1.1.0] - 2021-12-13 + +- Update Tailwind 3 +- Several improvements + +## [1.0.3] - 2021-12-10 + +- Alignment issue + +## [1.0.2] - 2021-11-23 + +- Alignment issue + +## [1.0.1] - 2021-11-22 + +Fix dashboard icon color + +## [1.0.0] - 2021-11-22 + +First release \ No newline at end of file diff --git a/roadrecon/frontend-ng/Dockerfile b/roadrecon/frontend-ng/Dockerfile new file mode 100644 index 00000000..a6a71b47 --- /dev/null +++ b/roadrecon/frontend-ng/Dockerfile @@ -0,0 +1,29 @@ +# Build stage +FROM node:latest AS builder + +COPY package*.json /usr/src/ +WORKDIR /usr/src/ +RUN npm install +ENV PATH /usr/src/node_modules/.bin:$PATH +COPY . /usr/src/app +WORKDIR /usr/src/app +RUN npm run docker + +# Development stage +FROM node:latest AS dev +COPY package*.json /usr/src/ +WORKDIR /usr/src/ +RUN npm install --include=dev +ENV PATH /usr/src/node_modules/.bin:$PATH +COPY . /usr/src/app +WORKDIR /usr/src/app +EXPOSE 5173 +CMD ["npm", "run", "dev"] + +# Production stage +FROM nginx:alpine AS prod +COPY ./nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /usr/src/app/dist /usr/share/nginx/html + +EXPOSE 5173 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/roadrecon/frontend-ng/README.md b/roadrecon/frontend-ng/README.md new file mode 100644 index 00000000..70ced9ec --- /dev/null +++ b/roadrecon/frontend-ng/README.md @@ -0,0 +1,13 @@ +# Roadrecon UI NG + +## New features + +- New Frontend based on Vite, VueJS and PrimeVue framework +- Backend pagination for better performance on large databases +- Added a Policies detail page + +## Credits + +- Dirk-jan Mollema as the original author of the backend and the tool suite +- Kevin Tellier for the new UI +- Template by [Cruip.com](https://cruip.com/) \ No newline at end of file diff --git a/roadrecon/frontend-ng/index.html b/roadrecon/frontend-ng/index.html new file mode 100644 index 00000000..c74165c7 --- /dev/null +++ b/roadrecon/frontend-ng/index.html @@ -0,0 +1,20 @@ + + + + + + + ROADrecon + + + +
+ + + diff --git a/roadrecon/frontend-ng/nginx.conf b/roadrecon/frontend-ng/nginx.conf new file mode 100644 index 00000000..2d97b216 --- /dev/null +++ b/roadrecon/frontend-ng/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 5173; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:5000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/roadrecon/frontend-ng/package.json b/roadrecon/frontend-ng/package.json new file mode 100644 index 00000000..8effba87 --- /dev/null +++ b/roadrecon/frontend-ng/package.json @@ -0,0 +1,37 @@ +{ + "name": "mosaic-vue", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "docker": "vite build --config vite.config.docker.js", + "preview": "vite preview" + }, + "dependencies": { + "@primevue/themes": "^4.0.7", + "@tailwindcss/forms": "^0.5.7", + "@vueuse/core": "^10.7.0", + "axios": "^1.7.7", + "clipboard": "^2.0.11", + "cors": "^2.8.5", + "dayjs": "^1.11.13", + "flatpickr": "^4.6.13", + "moment": "^2.29.4", + "primeicons": "^7.0.0", + "primevue": "^4.0.7", + "vite-svg-loader": "^5.1.0", + "vue": "^3.2.20", + "vue-flatpickr-component": "^11.0.3", + "vue-router": "^4.2.5", + "vue3-json-viewer": "^2.2.2" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.5", + "@vue/compiler-sfc": "^3.2.20", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "vite": "^5.4.5" + } +} diff --git a/roadrecon/frontend-ng/postcss.config.cjs b/roadrecon/frontend-ng/postcss.config.cjs new file mode 100644 index 00000000..96bb01e7 --- /dev/null +++ b/roadrecon/frontend-ng/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/roadrecon/frontend-ng/public/_redirects b/roadrecon/frontend-ng/public/_redirects new file mode 100644 index 00000000..f8243379 --- /dev/null +++ b/roadrecon/frontend-ng/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/roadrecon/frontend-ng/public/favicon.ico b/roadrecon/frontend-ng/public/favicon.ico new file mode 100644 index 00000000..df36fcfb Binary files /dev/null and b/roadrecon/frontend-ng/public/favicon.ico differ diff --git a/roadrecon/frontend-ng/src/App.vue b/roadrecon/frontend-ng/src/App.vue new file mode 100644 index 00000000..55e12f8b --- /dev/null +++ b/roadrecon/frontend-ng/src/App.vue @@ -0,0 +1,45 @@ + + + + + + diff --git a/roadrecon/frontend-ng/src/css/additional-styles/flatpickr.css b/roadrecon/frontend-ng/src/css/additional-styles/flatpickr.css new file mode 100644 index 00000000..c497d4bd --- /dev/null +++ b/roadrecon/frontend-ng/src/css/additional-styles/flatpickr.css @@ -0,0 +1,239 @@ +@import 'flatpickr/dist/flatpickr.min.css'; + +/* Customise flatpickr */ +* { + --calendarPadding: 24px; + --daySize: 36px; + --daysWidth: calc(var(--daySize)*7); +} + +@keyframes fpFadeInDown { + from { + opacity: 0; + transform: translate3d(0, -8px, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +.flatpickr-calendar { + border: inherit; + @apply bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700/60 left-1/2; + margin-left: calc(calc(var(--daysWidth) + calc(var(--calendarPadding)*2))*0.5*-1); + padding: var(--calendarPadding); + width: calc(var(--daysWidth) + calc(var(--calendarPadding)*2)); +} + +@screen lg { + .flatpickr-calendar { + @apply left-0 right-auto; + margin-left: 0; + } +} + +.flatpickr-right.flatpickr-calendar { + @apply right-0 left-auto; + margin-left: 0; +} + +.flatpickr-calendar.animate.open { + animation: fpFadeInDown 200ms ease-out; +} + +.flatpickr-calendar.static { + position: absolute; + top: calc(100% + 4px); +} + +.flatpickr-calendar.static.open { + z-index: 20; +} + +.flatpickr-days { + width: var(--daysWidth); +} + +.dayContainer { + width: var(--daysWidth); + min-width: var(--daysWidth); + max-width: var(--daysWidth); +} + +.flatpickr-day { + @apply bg-gray-50 dark:bg-gray-700/20 text-sm font-medium text-gray-600 dark:text-gray-100; + max-width: var(--daySize); + height: var(--daySize); + line-height: var(--daySize); +} + +.flatpickr-day, +.flatpickr-day.prevMonthDay, +.flatpickr-day.nextMonthDay { + border: none; +} + +.flatpickr-day.flatpickr-disabled, +.flatpickr-day.flatpickr-disabled:hover, +.flatpickr-day.prevMonthDay, +.flatpickr-day.nextMonthDay, +.flatpickr-day.notAllowed, +.flatpickr-day.notAllowed.prevMonthDay, +.flatpickr-day.notAllowed.nextMonthDay { + @apply bg-transparent; +} + +.flatpickr-day, +.flatpickr-day.prevMonthDay, +.flatpickr-day.nextMonthDay, +.flatpickr-day.selected.startRange, +.flatpickr-day.startRange.startRange, +.flatpickr-day.endRange.startRange, +.flatpickr-day.selected.endRange, +.flatpickr-day.startRange.endRange, +.flatpickr-day.endRange.endRange, +.flatpickr-day.selected.startRange.endRange, +.flatpickr-day.startRange.startRange.endRange, +.flatpickr-day.endRange.startRange.endRange { + border-radius: 0; +} + +.flatpickr-day.flatpickr-disabled, +.flatpickr-day.flatpickr-disabled:hover, +.flatpickr-day.prevMonthDay, +.flatpickr-day.nextMonthDay, +.flatpickr-day.notAllowed, +.flatpickr-day.notAllowed.prevMonthDay, +.flatpickr-day.notAllowed.nextMonthDay { + @apply text-gray-400 dark:text-gray-500; +} + +.rangeMode .flatpickr-day { + margin: 0; +} + +.flatpickr-day.selected, +.flatpickr-day.startRange, +.flatpickr-day.endRange, +.flatpickr-day.selected.inRange, +.flatpickr-day.startRange.inRange, +.flatpickr-day.endRange.inRange, +.flatpickr-day.selected:focus, +.flatpickr-day.startRange:focus, +.flatpickr-day.endRange:focus, +.flatpickr-day.selected:hover, +.flatpickr-day.startRange:hover, +.flatpickr-day.endRange:hover, +.flatpickr-day.selected.prevMonthDay, +.flatpickr-day.startRange.prevMonthDay, +.flatpickr-day.endRange.prevMonthDay, +.flatpickr-day.selected.nextMonthDay, +.flatpickr-day.startRange.nextMonthDay, +.flatpickr-day.endRange.nextMonthDay { + @apply bg-violet-600 text-violet-50; +} + +.flatpickr-day.inRange, +.flatpickr-day.prevMonthDay.inRange, +.flatpickr-day.nextMonthDay.inRange, +.flatpickr-day.today.inRange, +.flatpickr-day.prevMonthDay.today.inRange, +.flatpickr-day.nextMonthDay.today.inRange, +.flatpickr-day:hover, +.flatpickr-day.prevMonthDay:hover, +.flatpickr-day.nextMonthDay:hover, +.flatpickr-day:focus, +.flatpickr-day.prevMonthDay:focus, +.flatpickr-day.nextMonthDay:focus, +.flatpickr-day.today:hover, +.flatpickr-day.today:focus { + @apply bg-violet-500 text-violet-50; +} + +.flatpickr-day.inRange, +.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)), +.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)), +.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) { + box-shadow: none; +} + +.flatpickr-months { + align-items: center; + margin-top: -8px; + margin-bottom: 6px; +} + +.flatpickr-months .flatpickr-prev-month, +.flatpickr-months .flatpickr-next-month { + position: static; + height: auto; + @apply text-gray-400 hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-300; +} + +.flatpickr-months .flatpickr-prev-month svg, +.flatpickr-months .flatpickr-next-month svg { + width: 7px; + height: 11px; + fill: currentColor; +} + +.flatpickr-months .flatpickr-prev-month:hover svg, +.flatpickr-months .flatpickr-next-month:hover svg { + @apply fill-current; +} + +.flatpickr-months .flatpickr-prev-month { + margin-left: -10px; +} + +.flatpickr-months .flatpickr-next-month { + margin-right: -10px; +} + +.flatpickr-months .flatpickr-month { + @apply text-gray-800 dark:text-gray-100; + height: auto; + line-height: inherit; +} + +.flatpickr-current-month { + @apply text-sm font-medium; + position: static; + height: auto; + width: auto; + left: auto; + padding: 0; +} + +.flatpickr-current-month span.cur-month { + @apply font-medium m-0; +} + +.flatpickr-current-month span.cur-month:hover { + background: none; +} + +.flatpickr-current-month input.cur-year { + font-weight: inherit; + box-shadow: none !important; +} + +.numInputWrapper:hover { + background: none; +} + +.numInputWrapper span { + display: none; +} + +span.flatpickr-weekday { + @apply text-gray-400 dark:text-gray-500 font-medium text-xs; +} + +.flatpickr-calendar.arrowTop::before, +.flatpickr-calendar.arrowTop::after, +.flatpickr-calendar.arrowBottom::before, +.flatpickr-calendar.arrowBottom::after { + display: none; +} \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/css/additional-styles/utility-patterns.css b/roadrecon/frontend-ng/src/css/additional-styles/utility-patterns.css new file mode 100644 index 00000000..0fc35039 --- /dev/null +++ b/roadrecon/frontend-ng/src/css/additional-styles/utility-patterns.css @@ -0,0 +1,138 @@ +/* Typography */ +.h1 { + @apply text-4xl font-extrabold tracking-tighter; +} + +.h2 { + @apply text-3xl font-extrabold tracking-tighter; +} + +.h3 { + @apply text-3xl font-extrabold; +} + +.h4 { + @apply text-2xl font-extrabold tracking-tight; +} + +@screen md { + .h1 { + @apply text-5xl; + } + + .h2 { + @apply text-4xl; + } +} + +/* Buttons */ +.btn, +.btn-lg, +.btn-sm, +.btn-xs { + @apply font-medium text-sm inline-flex items-center justify-center border border-transparent rounded-lg leading-5 shadow-sm transition; +} + +.btn { + @apply px-3 py-2; +} + +.btn-lg { + @apply px-4 py-3; +} + +.btn-sm { + @apply px-2 py-1; +} + +.btn-xs { + @apply px-2 py-0.5; +} + +/* Forms */ +input[type="search"]::-webkit-search-decoration, +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-results-button, +input[type="search"]::-webkit-search-results-decoration { + -webkit-appearance: none; +} + +.form-input, +.form-textarea, +.form-multiselect, +.form-select, +.form-checkbox, +.form-radio { + @apply bg-white dark:bg-gray-900/30 border focus:ring-0 focus:ring-offset-0 dark:disabled:bg-gray-700/30 dark:disabled:border-gray-700 dark:disabled:hover:border-gray-700; +} + +.form-checkbox { + @apply rounded; +} + +.form-input, +.form-textarea, +.form-multiselect, +.form-select { + @apply text-sm text-gray-800 dark:text-gray-100 leading-5 py-2 px-3 border-gray-200 hover:border-gray-300 focus:border-gray-300 dark:border-gray-700/60 dark:hover:border-gray-600 dark:focus:border-gray-600 shadow-sm rounded-lg; +} + +.form-input, +.form-textarea { + @apply placeholder-gray-400 dark:placeholder-gray-500; +} + +.form-select { + @apply pr-10; +} + +.form-checkbox, +.form-radio { + @apply text-violet-500 checked:bg-violet-500 dark:checked:border-transparent border border-gray-300 focus:border-violet-300 dark:border-gray-700/60 dark:focus:border-violet-500/50; +} + +/* Switch element */ +.form-switch { + @apply relative select-none; + width: 44px; +} + +.form-switch label { + @apply block overflow-hidden cursor-pointer h-6 rounded-full; +} + +.form-switch label > span:first-child { + @apply absolute block rounded-full; + width: 20px; + height: 20px; + top: 2px; + left: 2px; + right: 50%; + transition: all .15s ease-out; +} + +.form-switch input[type="checkbox"]:checked + label { + @apply bg-violet-500; +} + +.form-switch input[type="checkbox"]:checked + label > span:first-child { + left: 22px; +} + +.form-switch input[type="checkbox"]:disabled + label { + @apply cursor-not-allowed bg-gray-100 dark:bg-gray-700/20 border border-gray-200 dark:border-gray-700/60; +} + +.form-switch input[type="checkbox"]:disabled + label > span:first-child { + @apply bg-gray-400 dark:bg-gray-600; +} + +/* Chrome, Safari and Opera */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +.no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/css/style.css b/roadrecon/frontend-ng/src/css/style.css new file mode 100644 index 00000000..531f05c2 --- /dev/null +++ b/roadrecon/frontend-ng/src/css/style.css @@ -0,0 +1,11 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=fallback'); + +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; + +/* Additional styles */ +@import 'additional-styles/utility-patterns.css'; +@import 'additional-styles/flatpickr.css'; + +@import 'tailwindcss/utilities'; +@import 'primeicons/primeicons' \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/images/logo.svg b/roadrecon/frontend-ng/src/images/logo.svg new file mode 100644 index 00000000..68b702ee --- /dev/null +++ b/roadrecon/frontend-ng/src/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/main.js b/roadrecon/frontend-ng/src/main.js new file mode 100644 index 00000000..a08878a7 --- /dev/null +++ b/roadrecon/frontend-ng/src/main.js @@ -0,0 +1,45 @@ +import { createApp } from 'vue' +import router from './router' +import App from './App.vue' +import axios from './plugins/axios' +import PrimeVue from 'primevue/config'; +import Noir from './presets/Noir.js'; +import ToastService from 'primevue/toastservice'; +import JsonViewer from "vue3-json-viewer"; + +import DataTable from 'primevue/datatable'; +import Column from 'primevue/column'; + +import './css/style.css' +import dayjs from 'dayjs' + +export const app = createApp(App) +app.use(router) +app.use(axios,{ + baseUrl: 'http://localhost:5000/' +}) +app.use(PrimeVue, { + theme: { + preset: Noir, + options: { + prefix: 'p', + darkModeSelector: '.p-dark', + cssLayer: false, + } + } +}); + +//Date conversion +app.config.globalProperties.$dayjs = dayjs + +//Notification service +app.use(ToastService) + +//JSON Viewer +app.use(JsonViewer); + +//Components +app.component("DataTable",DataTable) +app.component("Column",Column) + +app.mount('#app') diff --git a/roadrecon/frontend-ng/src/pages/AdministrativeUnits.vue b/roadrecon/frontend-ng/src/pages/AdministrativeUnits.vue new file mode 100644 index 00000000..6dc0fe18 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/AdministrativeUnits.vue @@ -0,0 +1,86 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/ApplicationRoles.vue b/roadrecon/frontend-ng/src/pages/ApplicationRoles.vue new file mode 100644 index 00000000..9da32e12 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/ApplicationRoles.vue @@ -0,0 +1,87 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/Applications.vue b/roadrecon/frontend-ng/src/pages/Applications.vue new file mode 100644 index 00000000..2e301538 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/Applications.vue @@ -0,0 +1,106 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/Dashboard.vue b/roadrecon/frontend-ng/src/pages/Dashboard.vue new file mode 100644 index 00000000..ee1eb0db --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/Dashboard.vue @@ -0,0 +1,407 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/Devices.vue b/roadrecon/frontend-ng/src/pages/Devices.vue new file mode 100644 index 00000000..ede4e8a7 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/Devices.vue @@ -0,0 +1,99 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/DirectoryRoles.vue b/roadrecon/frontend-ng/src/pages/DirectoryRoles.vue new file mode 100644 index 00000000..1eb44d62 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/DirectoryRoles.vue @@ -0,0 +1,120 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/Groups.vue b/roadrecon/frontend-ng/src/pages/Groups.vue new file mode 100644 index 00000000..e4c9ede6 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/Groups.vue @@ -0,0 +1,101 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/OAuth2Permissions.vue b/roadrecon/frontend-ng/src/pages/OAuth2Permissions.vue new file mode 100644 index 00000000..41041ebf --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/OAuth2Permissions.vue @@ -0,0 +1,88 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/Policies.vue b/roadrecon/frontend-ng/src/pages/Policies.vue new file mode 100644 index 00000000..5efa29fa --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/Policies.vue @@ -0,0 +1,727 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/RowDetail.vue b/roadrecon/frontend-ng/src/pages/RowDetail.vue new file mode 100644 index 00000000..1a42d5d1 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/RowDetail.vue @@ -0,0 +1,742 @@ + + + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/ServicePrincipals.vue b/roadrecon/frontend-ng/src/pages/ServicePrincipals.vue new file mode 100644 index 00000000..d3c8e488 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/ServicePrincipals.vue @@ -0,0 +1,105 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/pages/Users.vue b/roadrecon/frontend-ng/src/pages/Users.vue new file mode 100644 index 00000000..9fb9b096 --- /dev/null +++ b/roadrecon/frontend-ng/src/pages/Users.vue @@ -0,0 +1,101 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/partials/Banner.vue b/roadrecon/frontend-ng/src/partials/Banner.vue new file mode 100644 index 00000000..c9a55570 --- /dev/null +++ b/roadrecon/frontend-ng/src/partials/Banner.vue @@ -0,0 +1,28 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/partials/Header.vue b/roadrecon/frontend-ng/src/partials/Header.vue new file mode 100644 index 00000000..678d071b --- /dev/null +++ b/roadrecon/frontend-ng/src/partials/Header.vue @@ -0,0 +1,69 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/partials/Sidebar.vue b/roadrecon/frontend-ng/src/partials/Sidebar.vue new file mode 100644 index 00000000..81771abd --- /dev/null +++ b/roadrecon/frontend-ng/src/partials/Sidebar.vue @@ -0,0 +1,164 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/partials/SidebarLinkGroup.vue b/roadrecon/frontend-ng/src/partials/SidebarLinkGroup.vue new file mode 100644 index 00000000..4c6467c1 --- /dev/null +++ b/roadrecon/frontend-ng/src/partials/SidebarLinkGroup.vue @@ -0,0 +1,26 @@ + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/partials/dashboard/ObjectTable.vue b/roadrecon/frontend-ng/src/partials/dashboard/ObjectTable.vue new file mode 100644 index 00000000..89162120 --- /dev/null +++ b/roadrecon/frontend-ng/src/partials/dashboard/ObjectTable.vue @@ -0,0 +1,294 @@ + + + + + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/plugins/axios.ts b/roadrecon/frontend-ng/src/plugins/axios.ts new file mode 100644 index 00000000..5a21e250 --- /dev/null +++ b/roadrecon/frontend-ng/src/plugins/axios.ts @@ -0,0 +1,15 @@ +import axios from 'axios' +import type {App} from 'vue' + +interface AxiosOptions { + baseUrl?: string + token?: string +} + +export default { + install: (app: App, options: AxiosOptions) => { + app.config.globalProperties.$axios = axios.create({ + baseURL: options.baseUrl + }) + } +} \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/presets/Noir.js b/roadrecon/frontend-ng/src/presets/Noir.js new file mode 100644 index 00000000..b63a1b11 --- /dev/null +++ b/roadrecon/frontend-ng/src/presets/Noir.js @@ -0,0 +1,53 @@ +import { definePreset } from '@primevue/themes'; + import Aura from '@primevue/themes/aura'; + + const Noir = definePreset(Aura, { + semantic: { + primary: { + 50: '{surface.50}', + 100: '{surface.100}', + 200: '{surface.200}', + 300: '{surface.300}', + 400: '{surface.400}', + 500: '{surface.500}', + 600: '{surface.600}', + 700: '{surface.700}', + 800: '{surface.800}', + 900: '{surface.900}', + 950: '{surface.950}' + }, + colorScheme: { + light: { + primary: { + color: '{primary.950}', + contrastColor: '#ffffff', + hoverColor: '{primary.900}', + activeColor: '{primary.800}' + }, + highlight: { + background: '{primary.950}', + focusBackground: '{primary.700}', + color: '#ffffff', + focusColor: '#ffffff' + }, + }, + dark: { + primary: { + color: '{primary.50}', + contrastColor: '{primary.950}', + hoverColor: '{primary.100}', + activeColor: '{primary.200}' + }, + highlight: { + background: '{primary.50}', + focusBackground: '{primary.300}', + color: '{primary.950}', + focusColor: '{primary.950}' + } + } + } + } + }); + + export default Noir; + \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/router.js b/roadrecon/frontend-ng/src/router.js new file mode 100644 index 00000000..cf19de09 --- /dev/null +++ b/roadrecon/frontend-ng/src/router.js @@ -0,0 +1,113 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from './pages/Dashboard.vue' +import Users from './pages/Users.vue' +import Groups from './pages/Groups.vue' +import Devices from './pages/Devices.vue' +import AdministrativeUnits from './pages/AdministrativeUnits.vue' +import DirectoryRoles from './pages/DirectoryRoles.vue' +import Applications from './pages/Applications.vue' +import ServicePrincipals from './pages/ServicePrincipals.vue' +import ApplicationRoles from './pages/ApplicationRoles.vue' +import OAuth2Permissions from './pages/OAuth2Permissions.vue' +import Policies from './pages/Policies.vue' +import RowDetail from './pages/RowDetail.vue' + +const routerHistory = createWebHistory() + +export const routes = [ + { + path: '/', + name: 'Dashboard', + component: Dashboard, + props: {name: 'Dashboard'}, + icon: "pi pi-home" + }, + { + path: '/Users', + name: 'Users', + component: Users, + props: {name: 'Users'}, + icon: "pi pi-user" + }, + { + path: '/Groups', + name: 'Groups', + component: Groups, + props: {name: 'Groups'}, + icon: "pi pi-users" + }, + { + path: '/Devices', + name: 'Devices', + component: Devices, + props: {name: 'Devices'}, + icon: "pi pi-desktop" + }, + { + path: '/AdministrativeUnits', + name: 'Administrative Units', + component: AdministrativeUnits, + props: {name: 'Administrative Units'}, + icon: "pi pi-stop" + }, + { + path: '/DirectoryRoles', + name: 'Directory Roles', + component: DirectoryRoles, + props: {name: 'Directory Roles'}, + icon: "pi pi-stop" + }, + { + path: '/Applications', + name: 'Applications', + component: Applications, + props: {name: 'Applications'}, + icon: "pi pi-box" + }, + { + path: '/ServicePrincipals', + name: 'Service Principals', + component: ServicePrincipals, + props: {name: 'ServicePrincipals'}, + icon: "pi pi-crown" + }, + { + path: '/ApplicationRoles', + name: 'Application Roles', + component: ApplicationRoles, + props: {name: 'Application Roles'}, + icon: "pi pi-stop" + }, + { + path: '/OAuth2Permissions', + name: 'Oauth2 Permissions', + component: OAuth2Permissions, + props: {name: 'OAuth2 Permissions'}, + icon: "pi pi-stop" + }, + { + path: '/Policies', + name: 'Policies', + component: Policies, + props: {name: 'Policies'}, + icon: "pi pi-list-check" + }, + { + path: '/:objectType/:objectId', + name: 'RowDetail', + component: RowDetail, + hideNavbar: true, + }, + { + path: '/:catchAll(.*)', + redirect: '/' + } +] + +const router = createRouter({ + history: routerHistory, + routes: routes, + mode: 'history' +}) + +export default router diff --git a/roadrecon/frontend-ng/src/services/toast.js b/roadrecon/frontend-ng/src/services/toast.js new file mode 100644 index 00000000..a023e1f5 --- /dev/null +++ b/roadrecon/frontend-ng/src/services/toast.js @@ -0,0 +1,12 @@ +import {app} from '../main'; + +const lifeTime = 3000; + +export function showInfo(title = 'I am title', body = 'I am body') { + app.config.globalProperties.$toast.add({ severity: 'info', summary: title, detail: body, life: lifeTime }); +} + +export function showError(title = 'I am title', body = 'I am body') { + console.log("printing error") + app.config.globalProperties.$toast.add({ severity: 'error', summary: title, detail: body, life: lifeTime }); +} \ No newline at end of file diff --git a/roadrecon/frontend-ng/src/utils/Utils.js b/roadrecon/frontend-ng/src/utils/Utils.js new file mode 100644 index 00000000..d720abc3 --- /dev/null +++ b/roadrecon/frontend-ng/src/utils/Utils.js @@ -0,0 +1,34 @@ +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfigFile from '@tailwindConfig' + +export const tailwindConfig = () => { + return resolveConfig(tailwindConfigFile) +} + +export const hexToRGB = (h) => { + let r = 0; + let g = 0; + let b = 0; + if (h.length === 4) { + r = `0x${h[1]}${h[1]}`; + g = `0x${h[2]}${h[2]}`; + b = `0x${h[3]}${h[3]}`; + } else if (h.length === 7) { + r = `0x${h[1]}${h[2]}`; + g = `0x${h[3]}${h[4]}`; + b = `0x${h[5]}${h[6]}`; + } + return `${+r},${+g},${+b}`; +}; + +export const formatValue = (value) => Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumSignificantDigits: 3, + notation: 'compact', +}).format(value); + +export const formatThousands = (value) => Intl.NumberFormat('en-US', { + maximumSignificantDigits: 3, + notation: 'compact', +}).format(value); diff --git a/roadrecon/frontend-ng/tailwind.config.js b/roadrecon/frontend-ng/tailwind.config.js new file mode 100644 index 00000000..c70db3c2 --- /dev/null +++ b/roadrecon/frontend-ng/tailwind.config.js @@ -0,0 +1,139 @@ +import plugin from "tailwindcss/plugin"; +import forms from '@tailwindcss/forms'; + +export default { + content: [ + './index.html', + './src/**/*.{vue,js,ts,jsx,tsx}', + ], + darkMode: 'class', + theme: { + extend: { + colors: { + gray: { + 50: '#F9FAFB', + 100: '#F3F4F6', + 200: '#E5E7EB', + 300: '#BFC4CD', + 400: '#9CA3AF', + 500: '#6B7280', + 600: '#4B5563', + 700: '#374151', + 800: '#1F2937', + 900: '#111827', + 950: '#030712', + }, + violet: { + 50: '#F1EEFF', + 100: '#E6E1FF', + 200: '#D2CBFF', + 300: '#B7ACFF', + 400: '#9C8CFF', + 500: '#8470FF', + 600: '#755FF8', + 700: '#5D47DE', + 800: '#4634B1', + 900: '#2F227C', + 950: '#1C1357', + }, + sky: { + 50: '#E3F3FF', + 100: '#D1ECFF', + 200: '#B6E1FF', + 300: '#A0D7FF', + 400: '#7BC8FF', + 500: '#67BFFF', + 600: '#56B1F3', + 700: '#3193DA', + 800: '#1C71AE', + 900: '#124D79', + 950: '#0B324F', + }, + green: { + 50: '#D2FFE2', + 100: '#B1FDCD', + 200: '#8BF0B0', + 300: '#67E294', + 400: '#4BD37D', + 500: '#3EC972', + 600: '#34BD68', + 700: '#239F52', + 800: '#15773A', + 900: '#0F5429', + 950: '#0A3F1E', + }, + red: { + 50: '#FFE8E8', + 100: '#FFD1D1', + 200: '#FFB2B2', + 300: '#FF9494', + 400: '#FF7474', + 500: '#FF5656', + 600: '#FA4949', + 700: '#E63939', + 800: '#C52727', + 900: '#941818', + 950: '#600F0F', + }, + yellow: { + 50: '#FFF2C9', + 100: '#FFE7A0', + 200: '#FFE081', + 300: '#FFD968', + 400: '#F7CD4C', + 500: '#F0BB33', + 600: '#DFAD2B', + 700: '#BC9021', + 800: '#816316', + 900: '#4F3D0E', + 950: '#342809', + }, + }, + fontFamily: { + inter: ['Inter', 'sans-serif'], + }, + fontSize: { + xs: ['0.75rem', { lineHeight: '1.5' }], + sm: ['0.875rem', { lineHeight: '1.5715' }], + base: ['1rem', { lineHeight: '1.5', letterSpacing: '-0.01em' }], + lg: ['1.125rem', { lineHeight: '1.5', letterSpacing: '-0.01em' }], + xl: ['1.25rem', { lineHeight: '1.5', letterSpacing: '-0.01em' }], + '2xl': ['1.5rem', { lineHeight: '1.33', letterSpacing: '-0.01em' }], + '3xl': ['1.88rem', { lineHeight: '1.33', letterSpacing: '-0.01em' }], + '4xl': ['2.25rem', { lineHeight: '1.25', letterSpacing: '-0.02em' }], + '5xl': ['3rem', { lineHeight: '1.25', letterSpacing: '-0.02em' }], + '6xl': ['3.75rem', { lineHeight: '1.2', letterSpacing: '-0.02em' }], + }, + screens: { + xs: '480px', + }, + borderWidth: { + 3: '3px', + }, + minWidth: { + 36: '9rem', + 44: '11rem', + 56: '14rem', + 60: '15rem', + 72: '18rem', + 80: '20rem', + }, + maxWidth: { + '8xl': '88rem', + '9xl': '96rem', + }, + zIndex: { + 60: '60', + }, + }, + }, + plugins: [ + forms, + // add custom variant for expanding sidebar + plugin(({ addVariant, e }) => { + addVariant('sidebar-expanded', ({ modifySelectors, separator }) => { + modifySelectors(({ className }) => `.sidebar-expanded .${e(`sidebar-expanded${separator}${className}`)}`); + }); + }), + ], +}; diff --git a/roadrecon/frontend-ng/vite.config.docker.js b/roadrecon/frontend-ng/vite.config.docker.js new file mode 100644 index 00000000..3bd027ee --- /dev/null +++ b/roadrecon/frontend-ng/vite.config.docker.js @@ -0,0 +1,45 @@ +import path from 'path' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import svgLoader from 'vite-svg-loader' + +// https://vitejs.dev/config/ +export default defineConfig({ + define: { + 'process.env': process.env + }, + plugins: [vue(), svgLoader()], + resolve: { + alias: { + '@tailwindConfig': path.resolve(__dirname, 'tailwind.config.js'), + }, + }, + optimizeDeps: { + include: [ + '@tailwindConfig', + ] + }, + build: { + commonjsOptions: { + transformMixedEsModules: true, + } + }, + server:{ + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://backend:5000', + changeOrigin: true, + }, + }, + fs: { + allow: [ + '/usr/src/app/node_modules/primeicons/', + '/usr/src/app/node_modules/vue3-json-viewer/', + '/usr/src/app/node_modules/vite/dist/client', + '/usr/src/app/src' + ] + } + } +}) diff --git a/roadrecon/frontend-ng/vite.config.js b/roadrecon/frontend-ng/vite.config.js new file mode 100644 index 00000000..5daf5018 --- /dev/null +++ b/roadrecon/frontend-ng/vite.config.js @@ -0,0 +1,47 @@ +import path from 'path' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import svgLoader from 'vite-svg-loader' + +// https://vitejs.dev/config/ +export default defineConfig({ + define: { + 'process.env': process.env + }, + plugins: [vue(), svgLoader()], + resolve: { + alias: { + '@tailwindConfig': path.resolve(__dirname, 'tailwind.config.js'), + }, + }, + optimizeDeps: { + include: [ + '@tailwindConfig', + ] + }, + build: { + commonjsOptions: { + transformMixedEsModules: true, + }, + outDir: '../roadtools/roadrecon/dist_gui', + emptyOutDir: true, // also necessary + }, + server:{ + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://backend:5000', + changeOrigin: true, + }, + }, + fs: { + allow: [ + '/usr/src/app/node_modules/primeicons/', + '/usr/src/app/node_modules/vue3-json-viewer/', + '/usr/src/app/node_modules/vite/dist/client', + '/usr/src/app/src' + ] + } + } +}) diff --git a/roadrecon/roadtools/roadrecon/server.py b/roadrecon/roadtools/roadrecon/server.py index 1b0c6ff2..d3f01858 100644 --- a/roadrecon/roadtools/roadrecon/server.py +++ b/roadrecon/roadtools/roadrecon/server.py @@ -1,18 +1,28 @@ +import sys from flask import Flask, request, jsonify, abort, send_from_directory, redirect, send_file from flask_sqlalchemy import SQLAlchemy from flask_marshmallow import Marshmallow from flask_cors import CORS from marshmallow_sqlalchemy import ModelConverter from marshmallow import fields -from roadtools.roadlib.metadef.database import User, JSON, Group, DirectoryRole, ServicePrincipal, AppRoleAssignment, TenantDetail, Application, Device, OAuth2PermissionGrant, AuthorizationPolicy, DirectorySetting, AdministrativeUnit, RoleDefinition +from roadtools.roadlib.metadef.database import User, Policy, JSON, Group, DirectoryRole, ServicePrincipal, AppRoleAssignment, TenantDetail, Application, Device, OAuth2PermissionGrant, AuthorizationPolicy, DirectorySetting, AdministrativeUnit, RoleDefinition import os +import logging import argparse -from sqlalchemy import func, and_, or_, select +from sqlalchemy import func, and_, or_, select, desc, asc, cast +from sqlalchemy.event import listens_for +from sqlalchemy.pool import _ConnectionRecord import mimetypes +import json +import zlib +import base64 +from html import escape app = Flask(__name__) app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +logging.getLogger('werkzeug').setLevel(logging.DEBUG) + # This will get initialized later on db = None ma = Marshmallow(app) @@ -43,6 +53,11 @@ class Meta: model = User fields = ('objectId', 'objectType', 'userPrincipalName', 'displayName', 'mail', 'lastDirSyncTime', 'accountEnabled', 'department', 'lastPasswordChangeDateTime', 'jobTitle', 'mobile', 'dirSyncEnabled', 'strongAuthenticationDetail', 'userType', 'searchableDeviceKey') +class PoliciesSchema(ma.Schema): + class Meta: + model = Policy + fields = ('objectId', 'objectType', 'deletionTimestamp', 'displayName', 'keyCredentials', 'policyType', 'policyDetail', 'policyIdentifier', 'tenantDefaultPolicy') + class DevicesSchema(ma.Schema): class Meta: model = User @@ -51,7 +66,7 @@ class Meta: class DirectoryRoleSchema(ma.Schema): class Meta: model = DirectoryRole - fields = ('displayName', 'description') + fields = ('displayName', 'description', 'objectId', 'objectType', 'objectId', 'objectType') class OAuth2PermissionGrantsSchema(ma.SQLAlchemyAutoSchema): class Meta: @@ -111,6 +126,10 @@ class Meta(RTModelSchema.Meta): ownedApplications = fields.Nested(ApplicationsSchema, many=True) ownedGroups = fields.Nested(GroupsSchema, many=True) +class PolicySchema(RTModelSchema): + class Meta(RTModelSchema.Meta): + model = Policy + class DeviceSchema(RTModelSchema): class Meta(RTModelSchema.Meta): model = Device @@ -167,6 +186,7 @@ class Meta(RTModelSchema.Meta): # Instantiate all schemas user_schema = UserSchema() +policy_schema = PolicySchema() device_schema = DeviceSchema() group_schema = GroupSchema() application_schema = ApplicationSchema() @@ -176,6 +196,7 @@ class Meta(RTModelSchema.Meta): administrativeunit_schema = AdministrativeUnitSchema() authorizationpolicy_schema = AuthorizationPolicySchema(many=True) users_schema = UsersSchema(many=True) +policies_schema = PoliciesSchema(many=True) devices_schema = DevicesSchema(many=True) groups_schema = GroupsSchema(many=True) applications_schema = ApplicationsSchema(many=True) @@ -183,6 +204,114 @@ class Meta(RTModelSchema.Meta): directoryroles_schema = DirectoryRolesSchema(many=True) administrativeunits_schema = AdministrativeUnitsSchema(many=True) + +def _translate_locations(locs): + policies = db.session.query(Policy).filter(Policy.policyType == 6).all() + out = [] + # Not sure if there can be multiple + for policy in policies: + for pdetail in policy.policyDetail: + detaildata = json.loads(pdetail) + if 'KnownNetworkPolicies' in detaildata and detaildata['KnownNetworkPolicies']['NetworkId'] in locs: + out.append(detaildata['KnownNetworkPolicies']['NetworkName']) + # New format + for loc in locs: + policies = db.session.query(Policy).filter(Policy.policyType == 6, Policy.policyIdentifier == loc).all() + for policy in policies: + out.append(policy.displayName) + return out + +def parse_compressed_cidr(detail): + if not 'CompressedCidrIpRanges' in detail: + return '' + compressed = detail['CompressedCidrIpRanges'] + b = base64.b64decode(compressed) + cstr = zlib.decompress(b, -zlib.MAX_WBITS) + decoded_cidrs = escape(cstr.decode()).split(",") + return decoded_cidrs + +def parse_associated_policies(location_object, is_trusted_location,condition_policy_list): + found_pols = [] + + for pol in condition_policy_list: + if not pol.policyDetail: + continue + parsed = json.loads(pol.policyDetail[0]) + if not parsed.get('Conditions') or not parsed.get('Conditions').get('Locations'): + continue + + cloc = parsed.get('Conditions').get('Locations') + incl = cloc.get('Include') or [] + excl = cloc.get('Exclude') or [] + for i in incl: + if location_object in i.get('Locations') or (is_trusted_location and "AllTrusted" in i.get('Locations')): + found_pols.append(pol.displayName) + + for i in excl: + if location_object in i.get('Locations') or (is_trusted_location and "AllTrusted" in i.get('Locations')): + found_pols.append(pol.displayName) + + return found_pols + +# Function to build a dynamic filter +def build_dynamic_filter(schema, search_string): + search_string = f"%{search_string}%" # SQL wildcard for partial match + filters = [] + + # Iterate through each field defined in the schema's Meta class + for field in schema._declared_fields.keys(): + # Ensure the attribute exists in the User model + if hasattr(User, field): + filters.append(getattr(User, field).like(search_string)) # Build the filter for each field + + # Return an OR combination of all filters + return or_(*filters) + +def query_all_items(request,schema,model,fields): + page = request.args.get('page', type=int) + rows = request.args.get('rows', type=int) + search = request.args.get('search', type=str) + sortedField = request.args.get('sortedField', type=str) + sortOrder = request.args.get('sortOrder', type=int) + + query = db.session.query(model) + + if search: + # For now only search on the userPrincipalName and displayName fields, others will be added with advanced filtering + #filter = build_dynamic_filter(user_schema, search) + filters = [] + for field in fields: + filters.append(getattr(model,field).like(f'%{search}%')) + + query = query.filter(or_(*filters)) + + if sortedField: + field = getattr(model, sortedField) + if hasattr(field, 'type') and isinstance(field.type, JSON): + # For JSON fields, use the length of the JSON array for sorting + field = func.json_array_length(cast(field, db.Text)) + elif hasattr(field, 'property') and hasattr(field.property, 'direction'): + # Handle relationship fields + field = field.property.direction.mapper.class_.id + if sortOrder == 1: + query = query.order_by(field.desc()) + elif sortOrder == -1: + query = query.order_by(field.asc()) + + if page is None and rows is None: + all_items = query.all() + result = { + 'items': schema.dump(all_items), + 'total': None + } + else: + all_items = query.paginate(page=page, per_page=rows) + result = { + 'items': schema.dump(all_items), + 'total': all_items.total + } + return jsonify(result) + @app.route("/") def get_index(): return send_file('dist_gui/index.html') @@ -200,10 +329,7 @@ def get_gui(path): @app.route("/api/users", methods=["GET"]) def get_users(): - all_users = db.session.query(User).all() - result = users_schema.dump(all_users) - return jsonify(result) - + return query_all_items(request, users_schema, User, ["userPrincipalName"]) @app.route("/api/users/", methods=["GET"]) def user_detail(id): @@ -212,12 +338,237 @@ def user_detail(id): abort(404) return user_schema.jsonify(user) +@app.route("/api/policies", methods=["GET"]) +def get_policies(): + policies = db.session.query(Policy).filter(or_(Policy.policyType == 18,Policy.policyType == 6)).order_by(Policy.displayName.asc()).all() + results = policies_schema.dump(policies) + + for policy in results: + if policy['policyType'] == 18: + policy['policyDetail'] = json.loads(policy['policyDetail'][0]) + if 'Conditions' in policy['policyDetail']: + conditions = policy['policyDetail']['Conditions'] + if 'Applications' in conditions: + applications = conditions['Applications'] + for key in applications.keys(): + for (index, object_type) in enumerate(applications[key]): + resolved = [] + if 'Applications' in object_type: + if 'Applications' in applications[key][index]: + for app in applications[key][index]['Applications']: + if app == "All": + print(applications,file=sys.stderr) + break + # If its an appId (UUID) + elif len(app) == 36: + application = db.session.query(ServicePrincipal).filter(ServicePrincipal.appId == app).first() + if application is not None: + resolved.append({ + 'displayName': application.displayName, + 'objectId': app + }) + else: + resolved.append({ + 'displayName': app, + 'objectId': app + }) + # Already resolved, just pass + else: + resolved.append({ + 'displayName':app, + 'objectId':'None' + }) + if len(resolved) > 0: + applications[key][index]['Applications'] = resolved + else: + applications[key][index] = [] + if len(applications[key][index]) == 0: + del applications[key][index] + if len(applications[key]) == 0: + del conditions['Applications'] + if 'ServicePrincipals' in conditions: + serviceprincipals = conditions['ServicePrincipals'] + for key in serviceprincipals.keys(): + resolved = [] + for (index, object_type) in enumerate(serviceprincipals[key]): + if 'ServicePrincipals' in serviceprincipals[key][index]: + for sp in serviceprincipals[key][index]['ServicePrincipals']: + if sp == "All": + resolved.append({ + 'displayName':'All', + 'objectId':'None' + }) + # If its an objectId (UUID) + elif len(sp) == 36: + serviceprincipal = db.session.query(ServicePrincipal).filter(ServicePrincipal.objectId == sp).first() + if serviceprincipal is not None: + resolved.append({ + 'displayName': serviceprincipal.displayName, + 'objectId': sp + }) + else: + resolved.append({ + 'displayName': sp, + 'objectId': sp + }) + elif sp == "None": + pass + # Already resolved, just pass + else: + resolved.append({ + 'displayName': sp, + 'objectId':'None' + }) + serviceprincipals[key][index]['ServicePrincipals'] = resolved + if 'Users' in conditions: + users = conditions['Users'] + for key in users.keys(): + for (index, object_type) in enumerate(users[key]): + if 'Users' in object_type: + resolved = [] + if 'Users' in users[key][index]: + for usr in users[key][index]['Users']: + if usr == 'None': + users[key][index] = users[key][index].pop('Users') + break + if usr == "All": + resolved.append({ + 'displayName':'All', + 'objectId':'None' + }) + # If its an appId (UUID) + elif len(usr) == 36: + user = db.session.query(User).filter(User.objectId == usr).first() + if user is not None: + resolved.append({ + 'displayName': user.displayName, + 'objectId': usr + }) + else: + resolved.append({ + 'displayName': usr, + 'objectId': usr + }) + # Already resolved, just pass + else: + resolved.append({ + 'displayName': usr, + 'objectId':'None' + }) + if len(resolved) > 0: + users[key][index]['Users'] = resolved + if 'Groups' in object_type: + resolved = [] + if 'Groups' in users[key][index]: + for grp in users[key][index]['Groups']: + if grp == "All": + resolved.append({ + 'displayName':'All', + 'objectId':'None' + }) + # If its an appId (UUID) + elif len(grp) == 36: + group = db.session.query(Group).filter(Group.objectId == grp).first() + if group is not None: + resolved.append({ + 'displayName': group.displayName, + 'objectId': grp + }) + else: + resolved.append({ + 'displayName': grp, + 'objectId': grp + }) + elif grp == "None": + pass + # Already resolved, just pass + else: + resolved.append({ + 'displayName': grp, + 'objectId':'None' + }) + users[key][index]['Groups'] = resolved + if 'Roles' in object_type: + resolved = [] + if 'Roles' in users[key][index]: + for rle in users[key][index]['Roles']: + if rle == "All": + resolved.append({ + 'displayName':'All', + 'objectId':'None' + }) + # If its an appId (UUID) + elif len(rle) == 36: + role = db.session.query(RoleDefinition).filter(RoleDefinition.objectId == rle).first() + if role is not None: + resolved.append({ + 'displayName': role.displayName, + 'objectId': rle + }) + else: + resolved.append({ + 'displayName': rle, + 'objectId': rle + }) + elif rle == "None": + pass + # Already resolved, just pass + else: + resolved.append({ + 'displayName': rle, + 'objectId':'None' + }) + users[key][index]['Roles'] = resolved + #Cleaning up data from DB + keys_to_remove = [] + for key, value in users.items(): + if isinstance(value, list) and all(isinstance(item, list) and item == ["None"] for item in value): + keys_to_remove.append(key) + for key in keys_to_remove: + del users[key] + if not users: + del conditions['Users'] + if 'Locations' in conditions: + locations = conditions['Locations'] + for key in locations.keys(): + for (index, object_type) in enumerate(locations[key]): + if "All" not in object_type['Locations']: + translated = _translate_locations(object_type['Locations']) + else: + translated = object_type['Locations'] + conditions['Locations'][key] = translated + elif policy['policyType'] == 6: + policy['policyDetail'] = json.loads(policy['policyDetail'][0]) + + detail = None + oldpolicy = False + + if 'KnownNetworkPolicies' in policy['policyDetail']: + detail = policy['policyDetail']['KnownNetworkPolicies'] + oldpolicy = True + else: + detail = policy['policyDetail'] + if not oldpolicy: + policy['trusted'] = ("trusted" in detail.get("Categories","") if detail.get("Categories") else False) + policy['appliestounknowncountry'] = str(detail.get("ApplyToUnknownCountry")) if detail.get("ApplyToUnknownCountry") is not None else False + policy['ipranges'] = ",".join(parse_compressed_cidr(detail)) + policy['categories'] = ",".join(detail.get("Categories")) if detail.get("Categories") is not None else "" + policy['associated_policies'] = ",".join(parse_associated_policies(policy['policyIdentifier'],policy['trusted'],policies)) + policy['country_codes'] = ",".join(detail.get("CountryIsoCodes")) if detail.get("CountryIsoCodes") else None + else: + policy['name'] = detail.get("NetworkName") + policy['trusted'] = ("trusted" in detail.get("Categories","") if detail.get("Categories") else False) + policy['appliestounknowncountry'] = str(detail.get("ApplyToUnknownCountry")) if detail.get("ApplyToUnknownCountry") is not None else False + policy['ipranges'] = ",".join(detail.get('CidrIpRanges')) if detail.get("CidrIpRanges") else "" + policy['categories'] = ", ".join(detail.get("Categories")) if detail.get("Categories") is not None else "" + policy['associated_policies'] = ",".join(parse_associated_policies(detail.get('NetworkId'),policy['trusted'],policies)) + policy['country_codes'] = ",".join(detail.get("CountryIsoCodes")) if detail.get("CountryIsoCodes") else None + + return jsonify(results) + @app.route("/api/devices", methods=["GET"]) def get_devices(): - all_devices = db.session.query(Device).all() - result = devices_schema.dump(all_devices) - return jsonify(result) - + return query_all_items(request, devices_schema, Device, ["displayName"]) @app.route("/api/devices/", methods=["GET"]) def device_detail(id): @@ -236,9 +587,7 @@ def user_groups(id): @app.route("/api/groups", methods=["GET"]) def get_groups(): - all_groups = db.session.query(Group).all() - result = groups_schema.dump(all_groups) - return jsonify(result) + return query_all_items(request, groups_schema, Group, ["displayName"]) @app.route("/api/groups/", methods=["GET"]) def group_detail(id): @@ -249,9 +598,7 @@ def group_detail(id): @app.route("/api/administrativeunits", methods=["GET"]) def get_administrativeunits(): - all_administrativeunits = db.session.query(AdministrativeUnit).all() - result = administrativeunits_schema.dump(all_administrativeunits) - return jsonify(result) + return query_all_items(request, administrativeunits_schema, AdministrativeUnit, ["displayName"]) @app.route("/api/administrativeunits/", methods=["GET"]) def administrativeunit_detail(id): @@ -262,15 +609,22 @@ def administrativeunit_detail(id): @app.route("/api/serviceprincipals", methods=["GET"]) def get_sps(): - all_sps = db.session.query(ServicePrincipal).all() - return serviceprincipals_schema.jsonify(all_sps) + return query_all_items(request, serviceprincipals_schema, ServicePrincipal, ["displayName"]) @app.route("/api/serviceprincipals/", methods=["GET"]) def sp_detail(id): sp = db.session.get(ServicePrincipal, id) if not sp: abort(404) - return serviceprincipal_schema.jsonify(sp) + result = serviceprincipal_schema.dump(sp) + for (i,elem) in enumerate(sp.appRolesAssigned): + resource_data = get_approle_by_resources_(sp.appRolesAssigned[i].resourceId) + result['appRolesAssigned'][i]['desc'] = resource_data[0]['desc'] + result['appRolesAssigned'][i]['value'] = resource_data[0]['value'] + if len(sp.appRolesAssignedTo) > 0: + principal_data = get_approles_by_principal_(sp.appRolesAssigned[0].resourceId) + result['appRolesAssignedTo'] = principal_data + return jsonify(result) @app.route("/api/serviceprincipals-by-appid/", methods=["GET"]) def sp_detail_by_appid(id): @@ -281,9 +635,7 @@ def sp_detail_by_appid(id): @app.route("/api/applications", methods=["GET"]) def get_applications(): - all_applications = db.session.query(Application).all() - result = applications_schema.dump(all_applications) - return jsonify(result) + return query_all_items(request, applications_schema, Application, ["displayName"]) @app.route("/api/mfa", methods=["GET"]) def get_mfa(): @@ -420,51 +772,117 @@ def process_approle(approles, ar): if ar.principalType == 'Group': sp = db.session.get(Group, ar.principalId) if ar.id == '00000000-0000-0000-0000-000000000000': - approles.append({'objid':sp.objectId, - 'ptype':ar.principalType, - 'pname':sp.displayName, - 'app':ar.resourceDisplayName, - 'value':'Default', - 'desc':'Default Role', - 'spid':ar.resourceId, - }) + if sp is not None and ar is not None: + approles.append({'objectId':sp.objectId, + 'principalType':ar.principalType, + 'principalDisplayName':sp.displayName, + 'resourceDisplayName':ar.resourceDisplayName, + 'value':'Default', + 'desc':'Default Role', + 'spid':ar.resourceId, + }) else: for approle in rsp.appRoles: if approle['id'] == ar.id: - approles.append({'objid':sp.objectId, - 'ptype':ar.principalType, - 'pname':sp.displayName, - 'app':ar.resourceDisplayName, - 'value':approle['value'], - 'desc':approle['displayName'], - 'spid':ar.resourceId, - }) + if sp is not None and ar is not None: + approles.append({'objectId':sp.objectId, + 'principalType':ar.principalType, + 'principalDisplayName':sp.displayName, + 'resourceDisplayName':ar.resourceDisplayName, + 'value':approle['value'], + 'desc':approle['displayName'], + 'spid':ar.resourceId, + }) @app.route("/api/approles", methods=["GET"]) def get_approles(): + page = request.args.get('page', type=int) + rows = request.args.get('rows', type=int) + search = request.args.get('search', type=str) + sortedField = request.args.get('sortedField', type=str) + sortOrder = request.args.get('sortOrder', type=int) + approles = [] - for ar in db.session.query(AppRoleAssignment).all(): + query = db.session.query(AppRoleAssignment) + + if search: + # For now only search on the userPrincipalName and displayName fields, others will be added with advanced filtering + #filter = build_dynamic_filter(user_schema, search) + filters = [] + filters.append(AppRoleAssignment.principalDisplayName.like(f'%{search}%')) + + query = query.filter(or_(*filters)) + + if sortedField: + if sortOrder == 1: + query = query.order_by(getattr(AppRoleAssignment,sortedField).desc()) + elif sortOrder == -1: + query = query.order_by(getattr(AppRoleAssignment,sortedField).asc()) + + if page is None and rows is None: + result = query.all() + else: + result = query.paginate(page=page, per_page=rows) + + for ar in result: process_approle(approles, ar) - return jsonify(approles) + + result = {'items':approles,'total':result.total} -@app.route("/api/approles_by_resource/", methods=["GET"]) -def get_approles_by_resource(spid): + return jsonify(result) + +def get_approle_by_resources_(spid): approles = [] for ar in db.session.query(AppRoleAssignment).filter(AppRoleAssignment.resourceId == spid): process_approle(approles, ar) - return jsonify(approles) + return approles -@app.route("/api/approles_by_principal/", methods=["GET"]) -def get_approles_by_principal(pid): +@app.route("/api/approles_by_resource/", methods=["GET"]) +def get_approles_by_resource(spid): + return jsonify(get_approle_by_resources_(spid)) + +def get_approles_by_principal_(pid): approles = [] for ar in db.session.query(AppRoleAssignment).filter(AppRoleAssignment.principalId == pid): process_approle(approles, ar) - return jsonify(approles) + return approles + +@app.route("/api/approles_by_principal/", methods=["GET"]) +def get_approles_by_principal(pid): + jsonify(get_approles_by_principal_(pid)) @app.route("/api/oauth2permissions", methods=["GET"]) def get_oauth2permissions(): + page = request.args.get('page', type=int) + rows = request.args.get('rows', type=int) + search = request.args.get('search', type=str) + sortedField = request.args.get('sortedField', type=str) + sortOrder = request.args.get('sortOrder', type=int) + + query = db.session.query(OAuth2PermissionGrant) + + if search: + # For now only search on the userPrincipalName and displayName fields, others will be added with advanced filtering + #filter = build_dynamic_filter(user_schema, search) + filters = [] + filters.append(ServicePrincipal.displayName.like(f'%{search}%')) + + query = query.filter(or_(*filters)) + + if sortedField: + if sortOrder == 1: + query = query.order_by(getattr(OAuth2PermissionGrant,sortedField).desc()) + elif sortOrder == -1: + query = query.order_by(getattr(OAuth2PermissionGrant,sortedField).asc()) + + if page is None and rows is None: + result = query.all() + result.total = len(result) + else: + result = query.paginate(page=page, per_page=rows) + oauth2permissions = [] - for permgrant in db.session.query(OAuth2PermissionGrant).all(): + for permgrant in result: grant = {} rsp = db.session.get(ServicePrincipal, permgrant.clientId) if permgrant.consentType == 'Principal': @@ -484,7 +902,8 @@ def get_oauth2permissions(): grant['expiry'] = permgrant.expiryTime.strftime("%Y-%m-%dT%H:%M:%S") grant['scope'] = permgrant.scope oauth2permissions.append(grant) - return jsonify(oauth2permissions) + + return jsonify({'items':oauth2permissions,'total':result.total}) @app.route("/api/roledefinitions", methods=["GET"]) def get_allroles(): @@ -508,6 +927,8 @@ def get_allroles(): 'scopeIds': sids } principalType, principal = resolve_objectid(assignment.principalId) + if principal == None: + break aobj['principal'] = principal roleobj['assignments'].append(aobj) @@ -535,6 +956,8 @@ def get_allroles(): 'scopeIds': sids } principalType, principal = resolve_objectid(assignment.principalId) + if principal == None: + break aobj['principal'] = principal roleobj['assignments'].append(aobj) if principalType == 'Group': @@ -617,6 +1040,7 @@ def main(args=None): help='HTTP Server port (default=5000)', default=5000) args = parser.parse_args() + if not ':/' in args.database: if args.database[0] != '/': app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(os.getcwd(), args.database) @@ -628,7 +1052,7 @@ def main(args=None): if args.profile: from werkzeug.middleware.profiler import ProfilerMiddleware app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[5]) - app.run(debug=args.debug, port=args.port) + app.run(debug=args.debug, host='0.0.0.0', port=args.port) if __name__ == '__main__': main()