├── api ├── __init__.py ├── views │ ├── __init__.py │ ├── schemas │ │ ├── delete_message.py │ │ ├── metrics.py │ │ ├── group_memberships.py │ │ ├── role_memberships.py │ │ ├── access_requests.py │ │ ├── role_requests.py │ │ └── __init__.py │ ├── bugs_views.py │ ├── webhook_views.py │ ├── metrics_views.py │ ├── health_check_views.py │ ├── apps_views.py │ ├── tags_views.py │ ├── audit_views.py │ ├── users_views.py │ ├── role_requests_views.py │ ├── access_requests_views.py │ ├── exception_views.py │ ├── roles_views.py │ ├── groups_views.py │ ├── resources │ │ ├── bug.py │ │ ├── __init__.py │ │ └── metrics.py │ └── plugins_views.py ├── wsgi.py ├── services │ └── __init__.py ├── plugins │ ├── setup.py │ ├── __init__.py │ ├── conditional_access.py │ └── metrics_reporter.py ├── operations │ ├── constraints │ │ └── __init__.py │ ├── __init__.py │ ├── delete_tag.py │ ├── create_tag.py │ ├── delete_app.py │ └── delete_user.py ├── models │ ├── __init__.py │ ├── okta_group.py │ ├── access_request.py │ ├── tag.py │ └── app_group.py ├── apispec.py ├── pagination.py ├── log_filters.py ├── extensions.py └── access_config.py ├── tests ├── __init__.py ├── test_health_check.py ├── test_okta_retries.py ├── test_expire_access_request.py └── conftest.py ├── .husky ├── .gitignore └── pre-commit ├── migrations ├── __init__.py ├── versions │ ├── __init__.py │ ├── 0ed12d651875_add_app_group_lifecycle_plugin_to_app.py │ └── cbc5bb2f05b7_do_not_renew.py ├── README ├── script.py.mako ├── alembic.ini └── env.py ├── examples ├── plugins │ ├── notifications │ │ ├── __init__.py │ │ ├── requirements.txt │ │ └── setup.py │ ├── conditional_access │ │ ├── __init__.py │ │ ├── requirements.txt │ │ ├── setup.py │ │ ├── Readme.md │ │ └── conditional_access.py │ ├── notifications_slack │ │ ├── __init__.py │ │ ├── requirements.txt │ │ ├── setup.py │ │ └── README.md │ ├── datadog_metrics_reporter │ │ ├── __init__.py │ │ ├── requirements.txt │ │ ├── setup.py │ │ └── README.md │ ├── health_check_plugin │ │ ├── __init__.py │ │ ├── setup.py │ │ ├── README.md │ │ └── cli.py │ └── app_group_lifecycle_audit_logger │ │ ├── __init__.py │ │ └── setup.py └── kubernetes │ ├── namespace.yaml │ ├── service-account.yaml │ ├── service.yaml │ ├── cron-job-notify-users.yaml │ ├── cron-job-notify-owners.yaml │ ├── cron-job-syncer.yaml │ └── deployment.yaml ├── .ruff.toml ├── public ├── logo.png ├── favicon.ico ├── logo-square.png ├── robots.txt └── manifest.json ├── .prettierignore ├── .flaskenv ├── .env.psql.example ├── .testenv ├── tsconfig.paths.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── requirements-test.txt ├── src ├── pages │ ├── apps │ │ ├── components │ │ │ ├── index.tsx │ │ │ └── AppsHeader.tsx │ │ └── Delete.tsx │ ├── groups │ │ ├── AppLinkButton.tsx │ │ ├── RemoveOwnDirectAccess.tsx │ │ └── Delete.tsx │ ├── Error.tsx │ ├── users │ │ └── UserAvatar.tsx │ ├── NotFound.tsx │ ├── ComingSoon.tsx │ ├── tags │ │ └── Delete.tsx │ └── roles │ │ └── RemoveGroups.tsx ├── setupTests.ts ├── api │ ├── apiRequestBodies.ts │ ├── apiUtils.ts │ ├── apiFetcher.ts │ └── apiContext.ts ├── globals.d.ts ├── components │ ├── Loading.tsx │ ├── EmptyListEntry.tsx │ ├── AccessMethodChip.tsx │ ├── LinkTableRow.tsx │ ├── InlineReason.tsx │ ├── AvatarButton.tsx │ ├── Ending.tsx │ ├── Started.tsx │ ├── StatusFilter.tsx │ ├── icons │ │ └── MoreTime.tsx │ ├── Breadcrumbs.tsx │ ├── MarkdownDescription.tsx │ ├── CreatedReason.tsx │ ├── actions │ │ └── TablePaginationActions.tsx │ ├── TableTopBar.tsx │ ├── MembershipChip.tsx │ └── AccessHistory.tsx ├── mui.d.ts ├── tab-title.tsx ├── config │ ├── accessConfig.ts │ └── loadAccessConfig.js ├── authentication.tsx ├── authorization.tsx └── index.tsx ├── .prettierrc ├── setup.py ├── .github ├── workflows │ ├── semgrep.yml │ ├── lint.yml │ ├── docker-image.yml │ └── ci.yml └── dependabot.yml ├── .env.production.example ├── docker-compose.yml ├── openapi-codegen.config.ts ├── config └── config.default.json ├── tox.ini ├── requirements.txt ├── tsconfig.json ├── .mypy.ini ├── index.html ├── vite.config.ts ├── Dockerfile └── package.json /api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /api/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/versions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/plugins/notifications/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/plugins/conditional_access/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/plugins/notifications_slack/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/plugins/datadog_metrics_reporter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /examples/plugins/notifications/requirements.txt: -------------------------------------------------------------------------------- 1 | pluggy==1.5.0 2 | -------------------------------------------------------------------------------- /examples/plugins/conditional_access/requirements.txt: -------------------------------------------------------------------------------- 1 | pluggy==1.5.0 2 | -------------------------------------------------------------------------------- /api/wsgi.py: -------------------------------------------------------------------------------- 1 | from api.app import create_app 2 | 3 | app = create_app() 4 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # Allow lines to be as long as 120 characters. 2 | line-length = 120 3 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/access/HEAD/public/logo.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github 2 | .vscode 3 | venv 4 | **/*.md 5 | **/*.json 6 | **/*.html 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/access/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /examples/plugins/notifications_slack/requirements.txt: -------------------------------------------------------------------------------- 1 | pluggy==1.5.0 2 | slack-sdk==3.27.2 3 | -------------------------------------------------------------------------------- /.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP="api.app:create_app" 2 | FLASK_RUN_PORT=6060 3 | FLASK_ENV=development 4 | -------------------------------------------------------------------------------- /examples/plugins/datadog_metrics_reporter/requirements.txt: -------------------------------------------------------------------------------- 1 | pluggy==1.5.0 2 | datadog==0.49.0 3 | -------------------------------------------------------------------------------- /public/logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/access/HEAD/public/logo-square.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /api/services/__init__.py: -------------------------------------------------------------------------------- 1 | from api.services.okta_service import OktaService 2 | 3 | okta = OktaService() 4 | -------------------------------------------------------------------------------- /.env.psql.example: -------------------------------------------------------------------------------- 1 | # 2 | # NOTE: For more info on each value check out README.md 3 | # 4 | 5 | POSTGRES_USER=postgres 6 | POSTGRES_PASSWORD=postgres -------------------------------------------------------------------------------- /examples/kubernetes/namespace.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | labels: 6 | name: access 7 | name: access 8 | -------------------------------------------------------------------------------- /examples/kubernetes/service-account.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: access 6 | namespace: access 7 | -------------------------------------------------------------------------------- /.testenv: -------------------------------------------------------------------------------- 1 | DATABASE_URI=sqlite:///:memory: 2 | CLIENT_ORIGIN_URL=http://localhost:3000 3 | REACT_APP_API_SERVER_URL=http://localhost:6060 4 | FLASK_ENV=test 5 | -------------------------------------------------------------------------------- /tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@mui/styled-engine": ["./node_modules/@mui/styled-engine-sc"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "charliermarsh.ruff", 5 | "ms-python.vscode-pylance", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /api/views/schemas/delete_message.py: -------------------------------------------------------------------------------- 1 | from marshmallow import Schema, fields 2 | 3 | 4 | class DeleteMessageSchema(Schema): 5 | deleted = fields.Boolean(dump_only=True) 6 | -------------------------------------------------------------------------------- /api/plugins/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="access-plugins", 5 | install_requires=["pluggy==1.5.0"], 6 | packages=find_packages(), 7 | ) 8 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest==8.3.4 2 | pytest-flask==1.3.0 3 | pytest-runner==6.0.1 4 | pytest-factoryboy==2.7.0 5 | pytest-mock==3.14.0 6 | factory_boy==3.3.3 7 | types-factory-boy==0.4.1 -------------------------------------------------------------------------------- /src/pages/apps/components/index.tsx: -------------------------------------------------------------------------------- 1 | export {AppsAccordionListGroup} from './AppsAccordionListGroup'; 2 | export {AppsAdminActionGroup} from './AppsAdminActionGroup'; 3 | export * from './AppsHeader'; 4 | -------------------------------------------------------------------------------- /examples/plugins/health_check_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | 4 | def init_app(app: Flask) -> None: 5 | from .cli import health_command 6 | 7 | app.cli.add_command(health_command) 8 | -------------------------------------------------------------------------------- /examples/plugins/app_group_lifecycle_audit_logger/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | App Group Lifecycle Audit Logger Plugin 3 | 4 | Example plugin that demonstrates how to implement an app group lifecycle plugin. 5 | """ 6 | 7 | __version__ = "0.1.0" 8 | -------------------------------------------------------------------------------- /api/operations/constraints/__init__.py: -------------------------------------------------------------------------------- 1 | from api.operations.constraints.check_for_reason import CheckForReason 2 | from api.operations.constraints.check_for_self_add import CheckForSelfAdd 3 | 4 | __all__ = [ 5 | "CheckForReason", 6 | "CheckForSelfAdd", 7 | ] 8 | -------------------------------------------------------------------------------- /examples/kubernetes/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | labels: 6 | app: access 7 | name: access 8 | namespace: access 9 | spec: 10 | ports: 11 | - port: 443 12 | targetPort: 3000 13 | selector: 14 | app: access 15 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /examples/plugins/notifications/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="access-notifications", 5 | install_requires=["pluggy==1.5.0"], 6 | py_modules=["notifications"], 7 | entry_points={ 8 | "access_notifications": ["notifications = notifications"], 9 | }, 10 | ) 11 | -------------------------------------------------------------------------------- /examples/plugins/notifications_slack/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="access-notifications", 5 | install_requires=["pluggy==1.5.0"], 6 | py_modules=["notifications"], 7 | entry_points={ 8 | "access_notifications": ["notifications = notifications"], 9 | }, 10 | ) 11 | -------------------------------------------------------------------------------- /src/api/apiRequestBodies.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by @openapi-codegen 3 | * 4 | * @version 1.0.0 5 | */ 6 | import type * as Schemas from './apiSchemas'; 7 | 8 | export type Tag = Schemas.Tag; 9 | 10 | export type App = Schemas.App; 11 | 12 | export type PolymorphicGroup = Schemas.OktaGroup | Schemas.AppGroup | Schemas.RoleGroup; 13 | -------------------------------------------------------------------------------- /examples/plugins/conditional_access/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="access-conditional-access", 5 | install_requires=["pluggy==1.5.0"], 6 | py_modules=["conditional_access"], 7 | entry_points={ 8 | "access_conditional_access": ["conditional_access = conditional_access"], 9 | }, 10 | ) 11 | -------------------------------------------------------------------------------- /examples/plugins/datadog_metrics_reporter/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="access-metrics", 5 | install_requires=["pluggy==1.5.0", "datadog>=0.49.0"], 6 | py_modules=["metrics_reporter"], 7 | entry_points={ 8 | "access_metrics_reporter": ["metrics_reporter = metrics_reporter"], 9 | }, 10 | ) 11 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare const ACCESS_CONFIG: any; 2 | declare const APP_NAME: string; 3 | declare const REQUIRE_DESCRIPTIONS: boolean; 4 | 5 | interface ImportMetaEnv { 6 | readonly VITE_API_SERVER_URL: string; 7 | readonly VITE_SENTRY_RELEASE: string; 8 | readonly MODE: string; 9 | } 10 | 11 | interface ImportMeta { 12 | readonly env: ImportMetaEnv; 13 | } 14 | -------------------------------------------------------------------------------- /api/views/bugs_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api 4 | from api.views.resources import SentryProxyResource 5 | 6 | bp_name = "api-bugs" 7 | bp_url_prefix = "/api/bugs" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(SentryProxyResource, "/sentry", endpoint="sentry_bug") 13 | -------------------------------------------------------------------------------- /src/api/apiUtils.ts: -------------------------------------------------------------------------------- 1 | type ComputeRange = []> = Result['length'] extends N 2 | ? Result 3 | : ComputeRange; 4 | 5 | export type ClientErrorStatus = Exclude[number], ComputeRange<400>[number]>; 6 | export type ServerErrorStatus = Exclude[number], ComputeRange<500>[number]>; 7 | -------------------------------------------------------------------------------- /api/views/webhook_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask_restful import Api 3 | 4 | from api.views.resources import OktaWebhookResource 5 | 6 | bp_name = "api-webhooks" 7 | bp_url_prefix = "/api/webhooks" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(OktaWebhookResource, "/okta", endpoint="okta_webhook") 13 | -------------------------------------------------------------------------------- /src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import CircularProgress from '@mui/material/CircularProgress'; 3 | 4 | export default function Loading() { 5 | return ( 6 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/mui.d.ts: -------------------------------------------------------------------------------- 1 | import '@mui/material/styles'; 2 | 3 | declare module '@mui/material/styles' { 4 | interface Palette { 5 | highlight: { 6 | [variant: string]: Palette['primary']; 7 | }; 8 | } 9 | 10 | interface PaletteOptions { 11 | highlight?: { 12 | [variant: string]: PaletteOptions['primary']; 13 | }; 14 | } 15 | 16 | interface TypeText { 17 | accent: string; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/tab-title.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | 3 | interface ChangeTitleProps { 4 | title: string; 5 | } 6 | 7 | const ChangeTitle: React.FC = ({title}) => { 8 | useEffect(() => { 9 | document.title = title; 10 | 11 | return () => { 12 | document.title = 'Access'; 13 | }; 14 | }, [title]); 15 | 16 | return null; 17 | }; 18 | 19 | export default ChangeTitle; 20 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "bracketSpacing": false, 4 | "singleQuote": true, 5 | "bracketSameLine": true, 6 | "overrides": [ 7 | { 8 | "files": ["*.css", "*.styl"], 9 | "options": { 10 | "parser": "css" 11 | } 12 | }, 13 | { 14 | "files": ["*.ts", "*.tsx"], 15 | "options": { 16 | "parser": "typescript" 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Access: Run Server", 6 | "type": "python", 7 | "request": "launch", 8 | "module": "flask", 9 | "env": { 10 | "FLASK_APP": "api/app.py", 11 | "FLASK_DEBUG": "1" 12 | }, 13 | "args": ["run"], 14 | "jinja": true, 15 | "justMyCode": true 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Access", 3 | "name": "Access", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo-square.png", 12 | "type": "image/png", 13 | "sizes": "1140x1140" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } 21 | -------------------------------------------------------------------------------- /examples/plugins/health_check_plugin/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="health_check_plugin", 5 | version="0.1.0", 6 | packages=["health_check_plugin"], 7 | package_dir={"health_check_plugin": "."}, # Map package to current directory 8 | install_requires=[ 9 | "Flask", 10 | ], 11 | entry_points={ 12 | "flask.commands": [ 13 | "health=health_check_plugin.cli:health_command", 14 | ], 15 | }, 16 | ) 17 | -------------------------------------------------------------------------------- /api/views/metrics_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources.metrics import MetricsResource 5 | 6 | bp_name = "api-metrics" 7 | bp_url_prefix = "/api/metrics" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(MetricsResource, "", endpoint="metrics") 13 | 14 | 15 | def register_docs() -> None: 16 | docs.register(MetricsResource, blueprint=bp_name, endpoint="metrics") 17 | -------------------------------------------------------------------------------- /src/components/EmptyListEntry.tsx: -------------------------------------------------------------------------------- 1 | import {TableCellProps, TableRow, TableCell, Typography} from '@mui/material'; 2 | 3 | interface EmptyListEntryProps { 4 | cellProps?: TableCellProps; 5 | customText?: string; 6 | } 7 | 8 | export const EmptyListEntry: React.FC = ({cellProps, customText}) => { 9 | return ( 10 | 11 | 12 | 13 | {customText || 'None'} 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /tests/test_health_check.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, url_for 2 | from flask.testing import FlaskClient 3 | from flask_sqlalchemy import SQLAlchemy 4 | 5 | 6 | def test_health_check(app: Flask, client: FlaskClient, db: SQLAlchemy) -> None: 7 | # test unauthenticated requests by setting the current user email to "Unauthenticated" 8 | app.config["CURRENT_OKTA_USER_EMAIL"] = "Unauthenticated" 9 | 10 | # test 200 11 | health_check_url = url_for("api-health-check.health_check") 12 | rep = client.get(health_check_url) 13 | assert rep.status_code == 200 14 | -------------------------------------------------------------------------------- /api/views/health_check_views.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from flask import Blueprint 4 | from sqlalchemy import text 5 | 6 | from api.extensions import db 7 | 8 | bp_name = "api-health-check" 9 | bp_url_prefix = "/api/healthz" 10 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 11 | 12 | 13 | @bp.route("", methods=["GET"]) 14 | def health_check() -> Any: 15 | try: 16 | db.session.execute(text("SELECT 1")) 17 | except Exception as e: 18 | return {"status": "error", "error": str(e)}, 500 19 | 20 | return {"status": "ok"}, 200 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | __version__ = "0.1" 5 | 6 | setup( 7 | name="api", 8 | version=__version__, 9 | packages=find_packages(exclude=["tests"]), 10 | install_requires=[ 11 | "flask", 12 | "flask-sqlalchemy", 13 | "flask-restful", 14 | "flask-migrate", 15 | "flask-jwt-extended", 16 | "flask-marshmallow", 17 | "marshmallow-sqlalchemy", 18 | "python-dotenv", 19 | "apispec[yaml]", 20 | "apispec-webframeworks", 21 | ], 22 | ) 23 | -------------------------------------------------------------------------------- /src/config/accessConfig.ts: -------------------------------------------------------------------------------- 1 | export interface AccessConfig { 2 | ACCESS_TIME_LABELS: Record; 3 | DEFAULT_ACCESS_TIME: string; 4 | NAME_VALIDATION_PATTERN: string; 5 | NAME_VALIDATION_ERROR: string; 6 | } 7 | 8 | // use the globally-injected ACCESS_CONFIG from src/globals.d.ts, typed to AccessConfig interface 9 | // see src/config/config.default.json for the default config 10 | const accessConfig: AccessConfig = ACCESS_CONFIG as AccessConfig; 11 | 12 | export default accessConfig; 13 | 14 | export const appName = APP_NAME; 15 | export const requireDescriptions = REQUIRE_DESCRIPTIONS; 16 | -------------------------------------------------------------------------------- /src/authentication.tsx: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | 3 | import {useGetUserById} from './api/apiComponents'; 4 | 5 | import {OktaUser} from './api/apiSchemas'; 6 | 7 | export function useCurrentUser() { 8 | const {data: currentUserData} = useGetUserById({ 9 | pathParams: {userId: '@me'}, 10 | }); 11 | 12 | const currentUser = currentUserData ?? ({} as OktaUser); 13 | 14 | if (currentUser.id && currentUser.email) { 15 | Sentry.setUser({ 16 | id: currentUser.id, 17 | email: currentUser.email.toLowerCase(), 18 | }); 19 | } 20 | 21 | return currentUser; 22 | } 23 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /api/views/apps_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import AppList, AppResource 5 | 6 | bp_name = "api-apps" 7 | bp_url_prefix = "/api/apps" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(AppResource, "/", endpoint="app_by_id") 13 | api.add_resource(AppList, "", endpoint="apps") 14 | 15 | 16 | def register_docs() -> None: 17 | docs.register(AppResource, blueprint=bp_name, endpoint="app_by_id") 18 | docs.register(AppList, blueprint=bp_name, endpoint="apps") 19 | -------------------------------------------------------------------------------- /api/views/tags_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import TagList, TagResource 5 | 6 | bp_name = "api-tags" 7 | bp_url_prefix = "/api/tags" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(TagResource, "/", endpoint="tag_by_id") 13 | api.add_resource(TagList, "", endpoint="tags") 14 | 15 | 16 | def register_docs() -> None: 17 | docs.register(TagResource, blueprint=bp_name, endpoint="tag_by_id") 18 | docs.register(TagList, blueprint=bp_name, endpoint="tags") 19 | -------------------------------------------------------------------------------- /src/pages/groups/AppLinkButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from 'react-router-dom'; 2 | 3 | import {AppGroup} from '../../api/apiSchemas'; 4 | import AppIcon from '@mui/icons-material/AppShortcut'; 5 | import AvatarButton from '../../components/AvatarButton'; 6 | 7 | export default function AppLink({group}: {group: AppGroup}) { 8 | const navigate = useNavigate(); 9 | const deleted = group.app?.deleted_at != null; 10 | const handleClick = deleted ? undefined : () => navigate(`/apps/${group.app?.name}`); 11 | return } text={group.app?.name} strikethrough={deleted} onClick={handleClick} />; 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | name: Semgrep 2 | on: 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - main 7 | schedule: 8 | # random HH:MM to avoid a load spike on GitHub Actions at 00:00 9 | - cron: '25 15 * * 1' 10 | jobs: 11 | semgrep: 12 | name: semgrep/ci 13 | runs-on: ubuntu-22.04 14 | env: 15 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 16 | container: 17 | image: returntocorp/semgrep 18 | if: (github.actor != 'dependabot[bot]') 19 | steps: 20 | - uses: actions/checkout@v6 21 | with: 22 | persist-credentials: false 23 | - run: semgrep ci -------------------------------------------------------------------------------- /.env.production.example: -------------------------------------------------------------------------------- 1 | # 2 | # NOTE: For more info on each value check out README.md 3 | # 4 | 5 | OKTA_DOMAIN= 6 | OKTA_API_TOKEN= 7 | DATABASE_URI= 8 | CLIENT_ORIGIN_URL=http://localhost:3000 9 | VITE_API_SERVER_URL="" 10 | FLASK_SENTRY_DSN=https://@sentry.io/ 11 | REACT_SENTRY_DSN=https://@sentry.io/ 12 | CLOUDFLARE_TEAM_DOMAIN= 13 | CLOUDFLARE_APPLICATION_AUDIENCE= 14 | SECRET_KEY= 15 | OIDC_CLIENT_SECRETS= 16 | ACCESS_CONFIG_FILE= 17 | -------------------------------------------------------------------------------- /api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from api.models.core_models import ( 2 | AccessRequest, 3 | AccessRequestStatus, 4 | App, 5 | AppGroup, 6 | AppTagMap, 7 | OktaGroup, 8 | OktaGroupTagMap, 9 | OktaUser, 10 | OktaUserGroupMember, 11 | RoleGroup, 12 | RoleGroupMap, 13 | RoleRequest, 14 | Tag, 15 | ) 16 | 17 | __all__ = [ 18 | "AccessRequest", 19 | "AccessRequestStatus", 20 | "App", 21 | "AppGroup", 22 | "AppTagMap", 23 | "OktaGroup", 24 | "OktaGroupTagMap", 25 | "OktaUser", 26 | "OktaUserGroupMember", 27 | "RoleGroup", 28 | "RoleGroupMap", 29 | "RoleRequest", 30 | "Tag", 31 | ] 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | discord-access: 3 | build: . 4 | container_name: discord-access 5 | env_file: 6 | - .env.production 7 | # volumes: 8 | # - ./client_secrets.json:/app/client_secrets.json 9 | ports: 10 | - '3000:3000' 11 | restart: unless-stopped 12 | depends_on: 13 | - postgres 14 | 15 | postgres: 16 | image: postgres:16 17 | container_name: postgres 18 | env_file: 19 | - .env.psql 20 | volumes: 21 | - pgdata:/var/lib/postgresql/data # https://stackoverflow.com/a/45606440 22 | restart: unless-stopped 23 | 24 | volumes: 25 | pgdata: # https://stackoverflow.com/a/45606440 26 | -------------------------------------------------------------------------------- /openapi-codegen.config.ts: -------------------------------------------------------------------------------- 1 | import {generateSchemaTypes, generateReactQueryComponents} from '@openapi-codegen/typescript'; 2 | import {defineConfig} from '@openapi-codegen/cli'; 3 | export default defineConfig({ 4 | api: { 5 | from: { 6 | relativePath: 'api/swagger.json', 7 | source: 'file', 8 | }, 9 | outputDir: 'src/api', 10 | to: async (context) => { 11 | const filenamePrefix = 'api'; 12 | const {schemasFiles} = await generateSchemaTypes(context, { 13 | filenamePrefix, 14 | }); 15 | await generateReactQueryComponents(context, { 16 | filenamePrefix, 17 | schemasFiles, 18 | }); 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /api/models/okta_group.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from api.extensions import db 4 | from api.models.core_models import OktaUser, OktaUserGroupMember 5 | 6 | 7 | def get_group_managers(group_id: str) -> List[OktaUser]: 8 | return ( 9 | OktaUser.query.join(OktaUserGroupMember, OktaUser.id == OktaUserGroupMember.user_id) 10 | .filter(OktaUserGroupMember.group_id == group_id) 11 | .filter(OktaUserGroupMember.is_owner.is_(True)) 12 | .filter( 13 | db.or_( 14 | OktaUserGroupMember.ended_at.is_(None), 15 | OktaUserGroupMember.ended_at > db.func.now(), 16 | ) 17 | ) 18 | .all() 19 | ) 20 | -------------------------------------------------------------------------------- /api/views/audit_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import GroupRoleAuditResource, UserGroupAuditResource 5 | 6 | bp_name = "api-audit" 7 | bp_url_prefix = "/api/audit" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(UserGroupAuditResource, "/users", endpoint="users_and_groups") 13 | api.add_resource(GroupRoleAuditResource, "/groups", endpoint="groups_and_roles") 14 | 15 | 16 | def register_docs() -> None: 17 | docs.register(UserGroupAuditResource, blueprint=bp_name, endpoint="users_and_groups") 18 | docs.register(GroupRoleAuditResource, blueprint=bp_name, endpoint="groups_and_roles") 19 | -------------------------------------------------------------------------------- /api/views/users_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import UserAuditResource, UserList, UserResource 5 | 6 | bp_name = "api-users" 7 | bp_url_prefix = "/api/users" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(UserResource, "/", endpoint="user_by_id") 13 | api.add_resource(UserAuditResource, "//audit", endpoint="user_audit_by_id") 14 | api.add_resource(UserList, "", endpoint="users") 15 | 16 | 17 | def register_docs() -> None: 18 | docs.register(UserResource, blueprint=bp_name, endpoint="user_by_id") 19 | docs.register(UserList, blueprint=bp_name, endpoint="users") 20 | -------------------------------------------------------------------------------- /config/config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "FRONTEND": { 3 | "ACCESS_TIME_LABELS": { 4 | "43200": "12 Hours", 5 | "432000": "5 Days", 6 | "1209600": "Two Weeks", 7 | "2592000": "30 Days", 8 | "7776000": "90 Days", 9 | "indefinite": "Indefinite", 10 | "custom": "Custom" 11 | }, 12 | "DEFAULT_ACCESS_TIME": "1209600", 13 | "NAME_VALIDATION_PATTERN": "^[A-Z][A-Za-z0-9\\-]*$", 14 | "NAME_VALIDATION_ERROR": "Name must start capitalized and contain only alphanumeric characters or hyphens." 15 | }, 16 | "BACKEND": { 17 | "NAME_VALIDATION_PATTERN": "[A-Z][A-Za-z0-9-]*", 18 | "NAME_VALIDATION_ERROR": "name must start capitalized and contain only alphanumeric characters or hyphens." 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/views/role_requests_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import RoleRequestList, RoleRequestResource 5 | 6 | bp_name = "api-role-requests" 7 | bp_url_prefix = "/api/role-requests" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource( 13 | RoleRequestResource, 14 | "/", 15 | endpoint="role_request_by_id", 16 | ) 17 | api.add_resource(RoleRequestList, "", endpoint="role_requests") 18 | 19 | 20 | def register_docs() -> None: 21 | docs.register(RoleRequestResource, blueprint=bp_name, endpoint="role_request_by_id") 22 | docs.register(RoleRequestList, blueprint=bp_name, endpoint="role_requests") 23 | -------------------------------------------------------------------------------- /api/views/access_requests_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import AccessRequestList, AccessRequestResource 5 | 6 | bp_name = "api-access-requests" 7 | bp_url_prefix = "/api/requests" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource( 13 | AccessRequestResource, 14 | "/", 15 | endpoint="access_request_by_id", 16 | ) 17 | api.add_resource(AccessRequestList, "", endpoint="access_requests") 18 | 19 | 20 | def register_docs() -> None: 21 | docs.register(AccessRequestResource, blueprint=bp_name, endpoint="access_request_by_id") 22 | docs.register(AccessRequestList, blueprint=bp_name, endpoint="access_requests") 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{313} 4 | 5 | [testenv] 6 | deps= 7 | -rrequirements-test.txt 8 | -rrequirements.txt 9 | allowlist_externals=pytest 10 | setenv = 11 | DATABASE_URI = sqlite:///:memory: 12 | FLASK_ENV = test 13 | 14 | commands= 15 | pytest -s tests 16 | ruff check . 17 | mypy . 18 | 19 | 20 | [testenv:test] 21 | commands= 22 | pytest -s tests {posargs} 23 | 24 | [testenv:test-verbose] 25 | commands= 26 | pytest -rP tests {posargs} 27 | 28 | [testenv:test-with-postgresql] 29 | commands= 30 | pytest -s tests {posargs} 31 | setenv = 32 | DATABASE_URI = postgresql+pg8000://postgres:postgres@localhost:5432 33 | FLASK_ENV = test 34 | 35 | 36 | [testenv:ruff] 37 | commands = 38 | ruff check . 39 | ruff format --check --diff . 40 | 41 | [testenv:mypy] 42 | commands = 43 | mypy . 44 | -------------------------------------------------------------------------------- /api/views/exception_views.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from flask import Blueprint, jsonify, request 4 | from werkzeug import exceptions 5 | 6 | bp_name = "exceptions" 7 | bp = Blueprint(bp_name, __name__, static_folder="../../build") 8 | 9 | 10 | @bp.app_errorhandler(exceptions.InternalServerError) 11 | def _handle_internal_server_error(ex: exceptions.InternalServerError) -> Any: 12 | if request.path.startswith("/api/"): 13 | return jsonify(message=str(ex)), ex.code 14 | else: 15 | return ex 16 | 17 | 18 | @bp.app_errorhandler(exceptions.NotFound) 19 | def _handle_not_found_error(ex: exceptions.NotFound) -> Any: 20 | if request.path.startswith("/api/"): 21 | return {"message": "Not Found"}, ex.code 22 | else: 23 | # So that the React SPA functions 24 | return bp.send_static_file("index.html") 25 | -------------------------------------------------------------------------------- /src/components/AccessMethodChip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Chip from '@mui/material/Chip'; 3 | import {useNavigate} from 'react-router-dom'; 4 | import {RoleGroupMap} from '../api/apiSchemas'; 5 | 6 | interface AccessMethodChipProps { 7 | roleGroupMapping?: RoleGroupMap | null; 8 | } 9 | 10 | export default function AccessMethodChip({roleGroupMapping}: AccessMethodChipProps) { 11 | const navigate = useNavigate(); 12 | 13 | if (!roleGroupMapping) { 14 | return ; 15 | } 16 | 17 | return ( 18 | navigate(`/roles/${roleGroupMapping.role_group?.name}`)} 24 | sx={{cursor: 'pointer'}} 25 | /> 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/LinkTableRow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Link, TableRow} from '@mui/material'; 3 | import {Link as RouterLink, useNavigate} from 'react-router-dom'; 4 | 5 | interface LinkTableRowProps { 6 | to: string; 7 | children: React.ReactNode; 8 | onClick?: () => void; 9 | } 10 | 11 | export default function LinkTableRow({to, children}: LinkTableRowProps) { 12 | return ( 13 | 14 | theme.palette.action.hover, 25 | }, 26 | }}> 27 | {children} 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/InlineReason.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@mui/material/Typography'; 3 | import Box from '@mui/material/Box'; 4 | 5 | interface InlineReasonProps { 6 | reason?: string; 7 | } 8 | 9 | export default function InlineReason({reason}: InlineReasonProps) { 10 | if (!reason) { 11 | return ( 12 | 13 | No reason given 14 | 15 | ); 16 | } 17 | 18 | return ( 19 | 20 | 30 | {reason} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /examples/plugins/app_group_lifecycle_audit_logger/setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup script for the App Group Lifecycle Audit Logger Plugin. 3 | 4 | This registers the plugin with Access via the setuptools entry_points mechanism. 5 | """ 6 | 7 | from setuptools import setup 8 | 9 | setup( 10 | name="app_group_lifecycle_audit_logger", 11 | version="0.1.0", 12 | description="Example app group lifecycle plugin that logs all group events", 13 | author="Discord", 14 | packages=["app_group_lifecycle_audit_logger"], 15 | package_dir={"app_group_lifecycle_audit_logger": "."}, 16 | install_requires=[ 17 | "Flask", 18 | "SQLAlchemy", 19 | ], 20 | # Register the plugin with the app group lifecycle plugin system 21 | entry_points={ 22 | "access_app_group_lifecycle": [ 23 | "audit_logger=app_group_lifecycle_audit_logger.plugin:audit_logger_plugin", 24 | ], 25 | }, 26 | ) 27 | -------------------------------------------------------------------------------- /migrations/versions/0ed12d651875_add_app_group_lifecycle_plugin_to_app.py: -------------------------------------------------------------------------------- 1 | """add app_group_lifecycle_plugin to app 2 | 3 | Revision ID: 0ed12d651875 4 | Revises: cbc5bb2f05b7 5 | Create Date: 2025-10-24 21:03:01.388376 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | revision = "0ed12d651875" 13 | down_revision = "cbc5bb2f05b7" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | with op.batch_alter_table("app", schema=None) as batch_op: 20 | batch_op.add_column(sa.Column("app_group_lifecycle_plugin", sa.Unicode(length=255), nullable=True)) 21 | batch_op.add_column(sa.Column("plugin_data", sa.JSON(), nullable=False, server_default="{}")) 22 | 23 | 24 | def downgrade(): 25 | with op.batch_alter_table("app", schema=None) as batch_op: 26 | batch_op.drop_column("plugin_data") 27 | batch_op.drop_column("app_group_lifecycle_plugin") 28 | -------------------------------------------------------------------------------- /api/views/roles_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import RoleAuditResource, RoleList, RoleMemberResource, RoleResource 5 | 6 | bp_name = "api-roles" 7 | bp_url_prefix = "/api/roles" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(RoleResource, "/", endpoint="role_by_id") 13 | api.add_resource(RoleAuditResource, "//audit", endpoint="role_audit_by_id") 14 | api.add_resource(RoleMemberResource, "//members", endpoint="role_members_by_id") 15 | api.add_resource(RoleList, "", endpoint="roles") 16 | 17 | 18 | def register_docs() -> None: 19 | docs.register(RoleResource, blueprint=bp_name, endpoint="role_by_id") 20 | docs.register(RoleMemberResource, blueprint=bp_name, endpoint="role_members_by_id") 21 | docs.register(RoleList, blueprint=bp_name, endpoint="roles") 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | }, 6 | "[typescriptreact]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | }, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode", 11 | }, 12 | "[python]": { 13 | "editor.formatOnPaste": false, 14 | "editor.codeActionsOnSave": { 15 | "source.fixAll.ruff": "explicit", 16 | "source.organizeImports.ruff": "explicit" 17 | } 18 | }, 19 | "python.languageServer": "Pylance", 20 | "python.analysis.typeCheckingMode": "off", 21 | "python.terminal.activateEnvInCurrentTerminal": true, 22 | "python.analysis.inlayHints.variableTypes": false, 23 | "python.analysis.inlayHints.functionReturnTypes": false, 24 | "python.analysis.inlayHints.callArgumentNames": false, 25 | "mypy-type-checker.args": [ 26 | "--config-file=.mypy.ini" 27 | ], 28 | } -------------------------------------------------------------------------------- /api/views/groups_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import GroupAuditResource, GroupList, GroupMemberResource, GroupResource 5 | 6 | bp_name = "api-groups" 7 | bp_url_prefix = "/api/groups" 8 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 9 | 10 | api = Api(bp) 11 | 12 | api.add_resource(GroupResource, "/", endpoint="group_by_id") 13 | api.add_resource(GroupAuditResource, "//audit", endpoint="group_audit_by_id") 14 | api.add_resource(GroupMemberResource, "//members", endpoint="group_members_by_id") 15 | api.add_resource(GroupList, "", endpoint="groups") 16 | 17 | 18 | def register_docs() -> None: 19 | docs.register(GroupResource, blueprint=bp_name, endpoint="group_by_id") 20 | docs.register(GroupMemberResource, blueprint=bp_name, endpoint="group_members_by_id") 21 | docs.register(GroupList, blueprint=bp_name, endpoint="groups") 22 | -------------------------------------------------------------------------------- /src/components/AvatarButton.tsx: -------------------------------------------------------------------------------- 1 | import {Avatar, ButtonBase, Typography} from '@mui/material'; 2 | import {ReactNode} from 'react'; 3 | 4 | interface AvatarButtonProps { 5 | icon: ReactNode; 6 | text?: string; 7 | strikethrough?: boolean; 8 | onClick?: () => void; 9 | } 10 | 11 | export default function AvatarButton({icon, text, strikethrough, onClick}: AvatarButtonProps) { 12 | return ( 13 | 25 | {icon} 26 | {text && ( 27 | 28 | {text} 29 | 30 | )} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /api/models/access_request.py: -------------------------------------------------------------------------------- 1 | from typing import Set 2 | 3 | from api.models.app_group import get_access_owners, get_app_managers 4 | from api.models.core_models import AccessRequest, AppGroup, OktaUser, RoleRequest 5 | from api.models.okta_group import get_group_managers 6 | 7 | 8 | def get_all_possible_request_approvers(access_request: AccessRequest | RoleRequest) -> Set[OktaUser]: 9 | # This will return the entire set of possible access request approvers 10 | # to ensure that even if the resolved set of approvers changes 11 | # we still are able to mark the request as resolved for any users 12 | # that were notified of the request. 13 | group_owners = get_group_managers(access_request.requested_group_id) 14 | access_app_owners = get_access_owners() 15 | 16 | app_managers = [] 17 | 18 | if type(access_request.requested_group) is AppGroup: 19 | app_managers = get_app_managers(access_request.requested_group.app_id) 20 | 21 | return set(group_owners + access_app_owners + app_managers) 22 | -------------------------------------------------------------------------------- /src/components/Ending.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import RelativeTime from 'dayjs/plugin/relativeTime'; 3 | 4 | import {OktaUserGroupMember, RoleGroupMap} from '../api/apiSchemas'; 5 | 6 | dayjs.extend(RelativeTime); 7 | 8 | function selectLastTime( 9 | a: OktaUserGroupMember | RoleGroupMap, 10 | b: OktaUserGroupMember | RoleGroupMap, 11 | ): OktaUserGroupMember | RoleGroupMap { 12 | if (a.ended_at == null) return a; 13 | if (b.ended_at == null) return b; 14 | if (dayjs(a.ended_at).isAfter(dayjs(b.ended_at))) { 15 | return a; 16 | } else { 17 | return b; 18 | } 19 | } 20 | 21 | interface EndingProps { 22 | memberships: Array; 23 | } 24 | 25 | export default function Ending(props: EndingProps) { 26 | const lastMembership = props.memberships.reduce(selectLastTime); 27 | 28 | return lastMembership.ended_at == null ? ( 29 | Never 30 | ) : ( 31 | {dayjs(lastMembership.ended_at).startOf('second').fromNow()} 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /src/components/Started.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import RelativeTime from 'dayjs/plugin/relativeTime'; 3 | 4 | import {OktaUserGroupMember, RoleGroupMap} from '../api/apiSchemas'; 5 | 6 | dayjs.extend(RelativeTime); 7 | 8 | function selectFirstTime( 9 | a: OktaUserGroupMember | RoleGroupMap, 10 | b: OktaUserGroupMember | RoleGroupMap, 11 | ): OktaUserGroupMember | RoleGroupMap { 12 | if (a.created_at === undefined) { 13 | return a; 14 | } 15 | if (b.created_at === undefined) { 16 | return b; 17 | } 18 | if (a.created_at < b.created_at) { 19 | return a; 20 | } else { 21 | return b; 22 | } 23 | } 24 | 25 | interface StartedProps { 26 | memberships: Array; 27 | } 28 | 29 | export default function Started(props: StartedProps) { 30 | const firstMembership = props.memberships.reduce(selectFirstTime); 31 | 32 | return firstMembership.created_at == null ? ( 33 | Never 34 | ) : ( 35 | {dayjs(firstMembership.created_at).startOf('second').fromNow()} 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/StatusFilter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Select, {SelectChangeEvent} from '@mui/material/Select'; 3 | import MenuItem from '@mui/material/MenuItem'; 4 | import FormControl from '@mui/material/FormControl'; 5 | import InputLabel from '@mui/material/InputLabel'; 6 | 7 | export type StatusFilterValue = 'PENDING' | 'APPROVED' | 'REJECTED' | 'ALL'; 8 | 9 | interface StatusFilterProps { 10 | value: StatusFilterValue; 11 | onChange: (event: SelectChangeEvent) => void; 12 | } 13 | 14 | export default function StatusFilter({value, onChange}: StatusFilterProps) { 15 | return ( 16 | 17 | Status 18 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Flask 2 | click==8.1.7 3 | Flask==3.1.1 4 | Werkzeug==3.1.4 5 | gunicorn==23.0.0 6 | six==1.16.0 7 | python-dotenv==1.0.1 8 | python-dateutil==2.9.0.post0 9 | 10 | # Database 11 | Flask-SQLAlchemy==3.1.1 12 | SQLAlchemy==2.0.36 13 | Flask-Migrate==4.0.7 14 | sqlalchemy-json==0.7.0 15 | cloud-sql-python-connector==1.14.0 16 | pg8000==1.31.5 17 | 18 | # API and Models 19 | Flask-RESTful==0.3.10 20 | marshmallow==3.23.1 21 | marshmallow-sqlalchemy==1.1.0 22 | flask-marshmallow==1.2.1 23 | flask-apispec==0.11.4 24 | 25 | # Security 26 | Flask-Cors==6.0.0 27 | flask-talisman==1.1.0 28 | PyJWT==2.10.1 29 | flask-oidc==2.2.2 30 | 31 | # Requests 32 | requests==2.32.4 33 | 34 | # Error Reporting 35 | sentry-sdk[flask]==2.19.0 36 | 37 | # Okta 38 | okta==2.9.8 39 | 40 | # Plugins 41 | pluggy==1.5.0 42 | 43 | # Test - TODO: Move test packages to separate file 44 | tox==4.23.2 45 | # Lint 46 | ruff==0.8.0 47 | # Typing 48 | mypy==1.13.0 49 | types-google-cloud-ndb==2.3.0.20250317 50 | types-Flask-Cors==5.0.0.20240902 51 | types-Flask-Migrate==4.0.0.20240311 52 | types-python-dateutil==2.9.0.20250822 53 | types-requests==2.32.0.20241016 54 | -------------------------------------------------------------------------------- /src/components/icons/MoreTime.tsx: -------------------------------------------------------------------------------- 1 | import {createSvgIcon} from '@mui/material'; 2 | 3 | /** A version of the MUI `MoreTime` icon that's centered on the circle (instead of on the bounding box of all the elements) */ 4 | const MoreTime = createSvgIcon( 5 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | , 25 | 'MoreTime', 26 | ); 27 | 28 | export default MoreTime; 29 | -------------------------------------------------------------------------------- /src/pages/Error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@mui/material/Grid'; 3 | import Box from '@mui/material/Box'; 4 | import Typography from '@mui/material/Typography'; 5 | 6 | export default function NotFound() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | An Unrecoverable Error Occurred 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | pip-major: 9 | patterns: 10 | - "*" 11 | update-types: 12 | - "major" 13 | pip-minor: 14 | patterns: 15 | - "*" 16 | update-types: 17 | - "minor" 18 | - "patch" 19 | - package-ecosystem: "npm" 20 | directory: "/" 21 | schedule: 22 | interval: "weekly" 23 | groups: 24 | npm-major: 25 | patterns: 26 | - "*" 27 | update-types: 28 | - "major" 29 | npm-minor: 30 | patterns: 31 | - "*" 32 | update-types: 33 | - "minor" 34 | - "patch" 35 | - package-ecosystem: "github-actions" 36 | # Workflow files stored in the 37 | # default location of `.github/workflows` 38 | directory: "/" 39 | schedule: 40 | interval: "weekly" 41 | groups: 42 | github-action-group: 43 | patterns: 44 | - "*" 45 | - package-ecosystem: "docker" 46 | directory: "/" 47 | schedule: 48 | interval: "weekly" 49 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting and formatting checks 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | node-lint: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - uses: actions/checkout@v6 12 | with: 13 | persist-credentials: false 14 | - uses: actions/setup-node@v6 15 | with: 16 | node-version: 20 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Check prettier formatting 20 | run: npx prettier --check . 21 | python-lint: 22 | runs-on: ubuntu-22.04 23 | strategy: 24 | matrix: 25 | python-version: ["3.13"] 26 | steps: 27 | - uses: actions/checkout@v6 28 | with: 29 | persist-credentials: false 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v6 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | python -m pip install tox tox-gh-actions 38 | - name: Run ruff 39 | run: tox -e ruff 40 | - name: Run mypy 41 | run: tox -e mypy 42 | -------------------------------------------------------------------------------- /src/pages/users/UserAvatar.tsx: -------------------------------------------------------------------------------- 1 | import {PaletteMode, useTheme} from '@mui/material'; 2 | import Avatar from '@mui/material/Avatar'; 3 | import Typography from '@mui/material/Typography'; 4 | 5 | function stringToColor(string: string, mode: PaletteMode) { 6 | const hue = string.split('').reduce((acc, curr) => curr.charCodeAt(0) + acc, 0) % 360; 7 | if (mode === 'dark') { 8 | return `hsl(${hue}, 65%, 70%)`; 9 | } else { 10 | return `hsl(${hue}, 65%, 55%)`; 11 | } 12 | } 13 | 14 | interface UserAvatarProps { 15 | name: string; 16 | size?: number; 17 | variant?: string; 18 | } 19 | 20 | export default function UserAvatar(props: UserAvatarProps) { 21 | const { 22 | palette: {mode}, 23 | } = useTheme(); 24 | const splitName = props.name.split(' '); 25 | 26 | return ( 27 | 35 | 36 | {splitName.length > 1 ? splitName[0][0] + splitName[1][0] : splitName[0][0]} 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@mui/material/Grid'; 3 | import Paper from '@mui/material/Paper'; 4 | import Box from '@mui/material/Box'; 5 | import Typography from '@mui/material/Typography'; 6 | 7 | export default function NotFound() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Not Found 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/ComingSoon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@mui/material/Grid'; 3 | import Paper from '@mui/material/Paper'; 4 | import Box from '@mui/material/Box'; 5 | import Typography from '@mui/material/Typography'; 6 | 7 | export default function ComingSoon() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Coming Soon! 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.paths.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "jsx": "react-jsx", 7 | "noEmit": true, 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "lib": ["dom", "dom.iterable", "esnext"], 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "skipLibCheck": true, 19 | // Sentry options 20 | "sourceMap": true, 21 | "inlineSources": true, 22 | // Set `sourceRoot` to `/` to strip the build path prefix from 23 | // generated source code references. This allows Sentry to match source files 24 | // relative to your source root folder. 25 | "sourceRoot": "/", 26 | // ensure that ACCESS_CONFIG is made globally available 27 | "typeRoots": [ 28 | "./node_modules/@types", 29 | "./src/globals.d.ts" 30 | ], 31 | "paths": { 32 | "@mui/styled-engine": ["./node_modules/@mui/styled-engine-sc"] 33 | } 34 | }, 35 | "include": [ 36 | "src/**/*", 37 | "mui.d.ts", 38 | "src/globals.d.ts" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # https://mypy.readthedocs.io/en/stable/existing_code.html#introduce-stricter-options 3 | strict = True 4 | warn_return_any = False 5 | ignore_missing_imports = True 6 | 7 | [mypy-api.models.*] 8 | # Cannot seem to get flask-sqlalchemy db.Model subclassing to work with mypy 9 | # This didn't work https://stackoverflow.com/a/67578171 10 | disallow_subclassing_any = False 11 | 12 | [mypy-api.views.resources.*] 13 | # flask_apispec MethodResource is not typed 14 | disallow_subclassing_any = False 15 | # marshmallow-sqlalchemy SQLAlchemyAutoSchema constructors are not typed 16 | disallow_untyped_calls = False 17 | 18 | [mypy-api.views.schemas.*] 19 | # marshmallow-sqlalchemy SQLAlchemyAutoSchema constructors are not typed 20 | disallow_untyped_calls = False 21 | 22 | [mypy-api.extensions] 23 | # flask_restful Api is not typed 24 | disallow_subclassing_any = False 25 | 26 | [mypy-tests.factories] 27 | # Tried using types-factory-boy, but ran into "TypeError: type 'Faker' is not subscriptable" errors 28 | disallow_any_generics = False 29 | 30 | [mypy-migrations.*] 31 | # Don't require typing from auto-generated Alembic migrations 32 | disallow_untyped_calls = False 33 | disallow_untyped_defs = False 34 | 35 | # Known issue with mypy and Flask-SQLAlchemy https://github.com/python/mypy/issues/8603 36 | [mypy-flask_sqlalchemy.*] 37 | follow_imports=skip 38 | -------------------------------------------------------------------------------- /api/views/resources/bug.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | from urllib.parse import urlparse 3 | 4 | import requests 5 | from flask import current_app, request 6 | from flask_apispec import MethodResource 7 | 8 | 9 | # See more at 10 | # https://docs.sentry.io/platforms/javascript/troubleshooting/#dealing-with-ad-blockers 11 | # https://github.com/getsentry/examples/blob/master/tunneling/python/app.py 12 | class SentryProxyResource(MethodResource): 13 | def post(self) -> Dict[str, Any]: 14 | if current_app.config["ENV"] not in ("development", "test") and current_app.config["REACT_SENTRY_DSN"]: 15 | envelope = request.data 16 | dsn = urlparse(current_app.config["REACT_SENTRY_DSN"]) 17 | 18 | hostname = dsn.hostname 19 | project_id = dsn.path.strip("/") 20 | 21 | # Replace the client placeholder Sentry DSN with the one for the React app 22 | new_envelope = envelope.decode("utf-8").replace( 23 | "https://user@example.ingest.sentry.io/1234567", 24 | current_app.config["REACT_SENTRY_DSN"], 25 | ) 26 | 27 | requests.post( 28 | url=f"https://{hostname}/api/{project_id}/envelope/", 29 | data=new_envelope, 30 | headers={"Content-Type": "application/x-sentry-envelope"}, 31 | ) 32 | 33 | return {} 34 | -------------------------------------------------------------------------------- /api/views/schemas/metrics.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from marshmallow import Schema, ValidationError, fields, validate, validates_schema 4 | 5 | 6 | class MetricsDataSchema(Schema): 7 | """Schema for the data field of metrics.""" 8 | 9 | name = fields.String(required=False, validate=validate.Length(min=1, max=255)) 10 | value = fields.Float(required=False) 11 | duration = fields.Float(required=False) 12 | tags = fields.Dict(keys=fields.String(), values=fields.String(), required=False) 13 | buckets = fields.List(fields.Float(), required=False) 14 | 15 | @validates_schema 16 | def validate_data(self, data: Dict[str, Any], **kwargs: Any) -> None: 17 | # Ensure at least one value field is present 18 | if not any(key in data for key in ["value", "duration"]): 19 | raise ValidationError("Either 'value' or 'duration' must be provided in data") 20 | 21 | 22 | class MetricsSchema(Schema): 23 | # Metrics matching operations exposed from metrics reporter hook 24 | type = fields.String( 25 | required=True, 26 | validate=validate.OneOf(["counter", "gauge", "histogram", "timing"]), 27 | metadata={"description": "Type of metric to record"}, 28 | ) 29 | data = fields.Nested( 30 | MetricsDataSchema, required=True, metadata={"description": "Metric data including value and optional metadata"} 31 | ) 32 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import Breadcrumbs from '@mui/material/Breadcrumbs'; 2 | import Typography from '@mui/material/Typography'; 3 | 4 | import Link from '@mui/material/Link'; 5 | import {Link as RouterLink, useLocation} from 'react-router-dom'; 6 | 7 | export default function Crumbs() { 8 | const location = useLocation(); 9 | const pathnames = location.pathname.split('/').filter((x) => x); 10 | 11 | return ( 12 | 13 | 14 | Home 15 | 16 | {pathnames.map((value, index) => { 17 | const last = index === pathnames.length - 1; 18 | const to = `/${pathnames.slice(0, index + 1).join('/')}`; 19 | 20 | let display = decodeURI(value); 21 | if (new RegExp('^.*@.*\\..*$').test(display)) { 22 | display = display.toLowerCase(); 23 | } else { 24 | display = display[0].toUpperCase() + display.substring(1); 25 | } 26 | 27 | return last ? ( 28 | 29 | {display} 30 | 31 | ) : ( 32 | 33 | {display} 34 | 35 | ); 36 | })} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /migrations/versions/cbc5bb2f05b7_do_not_renew.py: -------------------------------------------------------------------------------- 1 | """do not renew 2 | 3 | Revision ID: cbc5bb2f05b7 4 | Revises: 6d2a03b326f9 5 | Create Date: 2025-06-27 13:55:16.355027 6 | 7 | """ 8 | 9 | from alembic import op 10 | import sqlalchemy as sa 11 | from sqlalchemy.sql import expression 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "cbc5bb2f05b7" 16 | down_revision = "6d2a03b326f9" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | with op.batch_alter_table("okta_user_group_member", schema=None) as batch_op: 24 | batch_op.add_column(sa.Column("should_expire", sa.Boolean(), server_default=expression.false(), nullable=False)) 25 | 26 | with op.batch_alter_table("role_group_map", schema=None) as batch_op: 27 | batch_op.add_column(sa.Column("should_expire", sa.Boolean(), server_default=expression.false(), nullable=False)) 28 | 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | with op.batch_alter_table("role_group_map", schema=None) as batch_op: 35 | batch_op.drop_column("should_expire") 36 | 37 | with op.batch_alter_table("okta_user_group_member", schema=None) as batch_op: 38 | batch_op.drop_column("should_expire") 39 | 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /examples/kubernetes/cron-job-notify-users.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: batch/v1 3 | kind: CronJob 4 | metadata: 5 | labels: 6 | app: access 7 | owner: security 8 | name: access-cronjob-notify 9 | namespace: access 10 | spec: 11 | concurrencyPolicy: Forbid 12 | jobTemplate: 13 | spec: 14 | backoffLimit: 0 15 | template: 16 | spec: 17 | containers: 18 | - command: 19 | - flask 20 | - notify 21 | env: # See "Production Setup" in the README for more details on configuring these environment variables 22 | - name: FLASK_ENV 23 | value: production 24 | - name: OKTA_DOMAIN 25 | value: mydomain.okta.com # Replace with your Okta domain 26 | - name: DATABASE_URI 27 | value: postgresql+pg8000:// # Replace with your database URI 28 | - name: OKTA_API_TOKEN 29 | valueFrom: 30 | secretKeyRef: 31 | key: OKTA_API_TOKEN 32 | name: access-secrets 33 | image: access # Replace with reference to a Docker image build of access in your container registry 34 | name: access-cronjob-notify-users 35 | restartPolicy: Never 36 | serviceAccountName: access 37 | ttlSecondsAfterFinished: 1800 38 | schedule: 0 18 * * 1-5 39 | -------------------------------------------------------------------------------- /examples/kubernetes/cron-job-notify-owners.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: batch/v1 3 | kind: CronJob 4 | metadata: 5 | labels: 6 | app: access 7 | owner: security 8 | name: access-cronjob-notify-owners 9 | namespace: access 10 | spec: 11 | concurrencyPolicy: Forbid 12 | jobTemplate: 13 | spec: 14 | backoffLimit: 0 15 | template: 16 | spec: 17 | containers: 18 | - command: 19 | - flask 20 | - notify 21 | - --owner 22 | env: # See "Production Setup" in the README for more details on configuring these environment variables 23 | - name: FLASK_ENV 24 | value: production 25 | - name: OKTA_DOMAIN 26 | value: mydomain.okta.com # Replace with your Okta domain 27 | - name: DATABASE_URI 28 | value: postgresql+pg8000:// # Replace with your database URI 29 | - name: OKTA_API_TOKEN 30 | valueFrom: 31 | secretKeyRef: 32 | key: OKTA_API_TOKEN 33 | name: access-secrets 34 | image: access # Replace with reference to a Docker image build of access in your container registry 35 | name: access-cronjob-notify-owners 36 | restartPolicy: Never 37 | serviceAccountName: access 38 | ttlSecondsAfterFinished: 1800 39 | schedule: 0 18 * * 1 40 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | Access 16 | 17 | 21 | 22 | 23 | 24 |
25 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /examples/plugins/conditional_access/Readme.md: -------------------------------------------------------------------------------- 1 | # Conditional Access Plugin 2 | 3 | This plugin will allow you to automatically approve or deny access requests based on the group or tag membership of the group. 4 | 5 | ## Installation 6 | 7 | Add the below to your Dockerfile to install the plugin. You can put it before the ENV section at the bottom of the file. 8 | ``` 9 | # Add the specific plugins and install conditional access plugin 10 | WORKDIR /app/plugins 11 | ADD ./examples/plugins/conditional_access ./conditional_access 12 | RUN pip install -r ./conditional_access/requirements.txt && pip install ./conditional_access 13 | 14 | # Reset working directory 15 | WORKDIR /app 16 | ``` 17 | 18 | Build and run your docker container as normal. 19 | 20 | 21 | ## Configuration 22 | 23 | You can set the following environment variables to configure the plugin but note that neither are required by default. If you only want to use the specific tag `Auto-Approve` then no environment variables are required. You must however create the tag within the Access Application. 24 | 25 | - `AUTO_APPROVED_GROUP_NAMES`: A comma-separated list of group names that will be auto-approved. 26 | - `AUTO_APPROVED_TAG_NAMES`: A comma-separated list of tag names that will be auto-approved. 27 | 28 | 29 | ## Usage 30 | 31 | The plugin will automatically approve access requests to the groups or tags specified in the environment variables by running a check on each access request that is processed. If neither the group name nor the tag name match, then a log line stating manual approval is required will be output. 32 | -------------------------------------------------------------------------------- /examples/kubernetes/cron-job-syncer.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: batch/v1 3 | kind: CronJob 4 | metadata: 5 | labels: 6 | app: access 7 | owner: security 8 | name: access-cronjob-syncer 9 | namespace: access 10 | spec: 11 | concurrencyPolicy: Forbid 12 | jobTemplate: 13 | spec: 14 | template: 15 | spec: 16 | containers: 17 | - command: 18 | - flask 19 | - sync 20 | # Enable this flag when you want Access to be the source-of-truth for group memberships overwriting Okta Admin membership changes 21 | # - --sync-group-memberships-authoritatively 22 | env: # See "Production Setup" in the README for more details on configuring these environment variables 23 | - name: FLASK_ENV 24 | value: production 25 | - name: OKTA_DOMAIN 26 | value: mydomain.okta.com # Replace with your Okta domain 27 | - name: DATABASE_URI 28 | value: postgresql+pg8000:// # Replace with your database URI 29 | - name: OKTA_API_TOKEN 30 | valueFrom: 31 | secretKeyRef: 32 | key: OKTA_API_TOKEN 33 | name: access-secrets 34 | image: access # Replace with reference to a Docker image build of access in your container registry 35 | name: access-cronjob-syncer 36 | restartPolicy: Never 37 | serviceAccountName: access 38 | ttlSecondsAfterFinished: 1800 39 | schedule: 0/15 * * * * 40 | -------------------------------------------------------------------------------- /api/models/tag.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime, timedelta 2 | from typing import Any, Optional 3 | 4 | from api.models.core_models import Tag 5 | 6 | 7 | def coalesce_constraints(constraint_key: str, tags: list[Tag]) -> Any: 8 | coalesced_constraint_value = None 9 | constraint = Tag.CONSTRAINTS[constraint_key] 10 | for tag in tags: 11 | if tag.enabled and constraint_key in tag.constraints: 12 | if coalesced_constraint_value is None: 13 | coalesced_constraint_value = tag.constraints[constraint_key] 14 | else: 15 | coalesced_constraint_value = constraint.coalesce( 16 | coalesced_constraint_value, tag.constraints[constraint_key] 17 | ) 18 | return coalesced_constraint_value 19 | 20 | 21 | def coalesce_ended_at( 22 | constraint_key: str, tags: list[Tag], initial_ended_at: Optional[datetime], group_is_managed: bool 23 | ) -> Optional[datetime]: 24 | # Only apply constraints if the group is managed 25 | if not group_is_managed: 26 | return initial_ended_at 27 | 28 | # Determine the minimum time allowed for group membership and ownership by current group tags 29 | seconds_limit = coalesce_constraints(constraint_key=constraint_key, tags=tags) 30 | if seconds_limit is None: 31 | return initial_ended_at 32 | else: 33 | constraint_ended_at = datetime.now(UTC) + timedelta(seconds=seconds_limit) 34 | if initial_ended_at is None: 35 | return constraint_ended_at 36 | else: 37 | return min(constraint_ended_at, initial_ended_at.replace(tzinfo=UTC)) 38 | -------------------------------------------------------------------------------- /src/components/MarkdownDescription.tsx: -------------------------------------------------------------------------------- 1 | import {Box, Typography} from '@mui/material'; 2 | import ReactMarkdown from 'react-markdown'; 3 | 4 | interface MarkdownDescriptionProps { 5 | description: string | null | undefined; 6 | } 7 | 8 | /** 9 | * Component for rendering markdown descriptions with proper styling. 10 | * Used on detail pages for apps, groups, and tags. 11 | */ 12 | export default function MarkdownDescription({description}: MarkdownDescriptionProps) { 13 | if (!description) { 14 | return null; 15 | } 16 | 17 | return ( 18 | 34 | 35 | 57 | {description} 58 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/authorization.tsx: -------------------------------------------------------------------------------- 1 | import {OktaUser, AppGroup, PolymorphicGroup} from './api/apiSchemas'; 2 | import {appName} from './config/accessConfig'; 3 | 4 | export function isGroupOwner(currentUser: OktaUser, groupId: string): boolean { 5 | const found = (currentUser.active_group_ownerships ?? []).find((ownership) => { 6 | return ownership.active_group?.id == groupId; 7 | }); 8 | return found != null; 9 | } 10 | 11 | export function isAppOwnerGroupOwner(currentUser: OktaUser, appId: string): boolean { 12 | const found = (currentUser.active_group_ownerships ?? []).find((ownership) => { 13 | if (ownership.active_group?.type == 'app_group') { 14 | const appGroup = ownership.active_group as AppGroup; 15 | return appGroup.is_owner && appGroup.app?.id == appId; 16 | } 17 | return false; 18 | }); 19 | return found != null; 20 | } 21 | 22 | export const ACCESS_APP_RESERVED_NAME = appName; 23 | 24 | export function isAccessAdmin(currentUser: OktaUser): boolean { 25 | const found = (currentUser.active_group_memberships ?? []).find((membership) => { 26 | if (membership.active_group?.type == 'app_group') { 27 | const appGroup = membership.active_group as AppGroup; 28 | return appGroup.is_owner && appGroup.app?.name == ACCESS_APP_RESERVED_NAME; 29 | } 30 | return false; 31 | }); 32 | return found != null; 33 | } 34 | 35 | // Helper combining all three methods above 36 | export function canManageGroup(currentUser: OktaUser, group: PolymorphicGroup | undefined) { 37 | return ( 38 | isAccessAdmin(currentUser) || 39 | isGroupOwner(currentUser, group?.id ?? '') || 40 | (group?.type == 'app_group' && isAppOwnerGroupOwner(currentUser, (group as AppGroup).app?.id ?? '')) 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/CreatedReason.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Avatar from '@mui/material/Avatar'; 4 | import Button from '@mui/material/Button'; 5 | import Dialog from '@mui/material/Dialog'; 6 | import DialogActions from '@mui/material/DialogActions'; 7 | import DialogContent from '@mui/material/DialogContent'; 8 | import DialogTitle from '@mui/material/DialogTitle'; 9 | 10 | interface CreatedReasonButtonProps { 11 | setOpen(open: boolean): any; 12 | } 13 | 14 | function CreatedReasonButton(props: CreatedReasonButtonProps) { 15 | return ( 16 | 19 | ); 20 | } 21 | 22 | interface CreatedReasonDialogProps { 23 | created_reason: string; 24 | setOpen(open: boolean): any; 25 | } 26 | 27 | function CreatedReasonDialog(props: CreatedReasonDialogProps) { 28 | return ( 29 | props.setOpen(false)}> 30 | Justification 31 | {props.created_reason} 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | interface CreatedReasonProps { 40 | created_reason?: string; 41 | } 42 | 43 | export default function CreatedReason(props: CreatedReasonProps) { 44 | const [open, setOpen] = React.useState(false); 45 | 46 | if (!props.created_reason) { 47 | return null; 48 | } 49 | 50 | return ( 51 | <> 52 | 53 | {open ? : null} 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /api/operations/__init__.py: -------------------------------------------------------------------------------- 1 | from api.operations.approve_access_request import ApproveAccessRequest 2 | from api.operations.create_access_request import CreateAccessRequest 3 | from api.operations.reject_access_request import RejectAccessRequest 4 | from api.operations.approve_role_request import ApproveRoleRequest 5 | from api.operations.create_role_request import CreateRoleRequest 6 | from api.operations.reject_role_request import RejectRoleRequest 7 | from api.operations.create_group import CreateGroup 8 | from api.operations.create_app import CreateApp 9 | from api.operations.create_tag import CreateTag 10 | from api.operations.delete_app import DeleteApp 11 | from api.operations.delete_group import DeleteGroup 12 | from api.operations.delete_tag import DeleteTag 13 | from api.operations.delete_user import DeleteUser 14 | from api.operations.modify_groups_time_limit import ModifyGroupsTimeLimit 15 | from api.operations.modify_app_tags import ModifyAppTags 16 | from api.operations.modify_group_tags import ModifyGroupTags 17 | from api.operations.modify_group_type import ModifyGroupType 18 | from api.operations.modify_group_users import ModifyGroupUsers 19 | from api.operations.modify_role_groups import ModifyRoleGroups 20 | from api.operations.unmanage_group import UnmanageGroup 21 | 22 | __all__ = [ 23 | "CreateAccessRequest", 24 | "ApproveAccessRequest", 25 | "RejectAccessRequest", 26 | "CreateRoleRequest", 27 | "ApproveRoleRequest", 28 | "RejectRoleRequest", 29 | "CreateApp", 30 | "CreateTag", 31 | "DeleteApp", 32 | "CreateGroup", 33 | "ModifyAppTags", 34 | "ModifyGroupTags", 35 | "ModifyGroupType", 36 | "ModifyGroupUsers", 37 | "ModifyGroupsTimeLimit", 38 | "DeleteGroup", 39 | "ModifyRoleGroups", 40 | "DeleteTag", 41 | "DeleteUser", 42 | "UnmanageGroup", 43 | ] 44 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | docker-release: 10 | name: Docker release to Google Artifact Registry 11 | runs-on: ubuntu-22.04 12 | 13 | permissions: 14 | contents: "read" 15 | id-token: "write" 16 | 17 | steps: 18 | - id: checkout 19 | name: Checkout 20 | uses: actions/checkout@v6 21 | with: 22 | persist-credentials: false 23 | 24 | - id: auth 25 | name: Authenticate with Google Cloud 26 | uses: google-github-actions/auth@v3 27 | with: 28 | token_format: access_token 29 | workload_identity_provider: projects/259610024247/locations/global/workloadIdentityPools/github-actions/providers/github-actions-access 30 | service_account: github-actions@discord-access-prd.iam.gserviceaccount.com 31 | access_token_lifetime: 300s 32 | 33 | - name: Login to Artifact Registry 34 | uses: docker/login-action@v3 35 | with: 36 | registry: us-east1-docker.pkg.dev 37 | username: oauth2accesstoken 38 | password: ${{ steps.auth.outputs.access_token }} 39 | 40 | - id: docker-push-tagged 41 | name: Tag Docker image and push to Google Artifact Registry 42 | uses: docker/build-push-action@v6 43 | with: 44 | push: true 45 | tags: | 46 | us-east1-docker.pkg.dev/discord-access-prd/access/access:latest 47 | build-args: | 48 | SENTRY_RELEASE=${{ github.sha }} 49 | PUSH_SENTRY_RELEASE=true 50 | secrets: | 51 | "SENTRY_ORG=${{ secrets.SENTRY_ORG }}" 52 | "SENTRY_PROJECT=${{ secrets.SENTRY_PROJECT }}" 53 | "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" 54 | -------------------------------------------------------------------------------- /api/apispec.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Optional, TypeVar, cast 2 | 3 | from flask import Flask 4 | from flask_apispec import MethodResource, marshal_with, use_kwargs 5 | from flask_apispec.extension import FlaskApiSpec 6 | from marshmallow import Schema 7 | 8 | 9 | class FlaskApiSpecExt: 10 | """Very simple and small extension to use apispec with this API as a flask extension""" 11 | 12 | def __init__(self, app: Optional[Flask] = None, **kwargs: dict[str, Any]) -> None: 13 | if app is not None: 14 | self.init_app(app, **kwargs) 15 | 16 | def init_app(self, app: Flask, **kwargs: dict[str, Any]) -> None: 17 | app.config.setdefault("APISPEC_TITLE", "access") 18 | app.config.setdefault("APISPEC_VERSION", "1.0.0") 19 | app.config.setdefault("APISPEC_SWAGGER_URL", "/api/swagger.json") 20 | app.config.setdefault("APISPEC_SWAGGER_UI_URL", "/api/swagger-ui") 21 | 22 | self.spec = FlaskApiSpec(app) 23 | 24 | def register( 25 | self, 26 | target: MethodResource, 27 | endpoint: Optional[str] = None, 28 | blueprint: Optional[str] = None, 29 | ) -> None: 30 | self.spec.register(target, endpoint, blueprint) 31 | 32 | 33 | F = TypeVar("F", bound=Callable[..., Any]) 34 | 35 | 36 | class FlaskApiSpecDecorators: 37 | @staticmethod 38 | def request_schema(schema: type[Schema], **kwargs: str) -> Callable[[F], F]: 39 | def wrapper(func: F) -> F: 40 | return cast(F, use_kwargs(schema, apply=False, **kwargs)(func)) 41 | 42 | return wrapper 43 | 44 | @staticmethod 45 | def response_schema(schema: type[Schema], **kwargs: str) -> Callable[[F], F]: 46 | def wrapper(func: F) -> F: 47 | return cast(F, marshal_with(schema, apply=False, **kwargs)(func)) 48 | 49 | return wrapper 50 | -------------------------------------------------------------------------------- /api/pagination.py: -------------------------------------------------------------------------------- 1 | """Simple helper to paginate query""" 2 | 3 | from typing import Any, Dict, Optional, Tuple 4 | 5 | from flask import request, url_for 6 | from flask_sqlalchemy import SQLAlchemy 7 | from marshmallow import Schema 8 | 9 | DEFAULT_PAGE_SIZE = 50 10 | DEFAULT_PAGE_NUMBER = 0 11 | 12 | 13 | def extract_pagination( 14 | page: Optional[int] = None, per_page: Optional[int] = None, **request_args: Dict[str, Any] 15 | ) -> Tuple[int, int, Dict[str, Any]]: 16 | page = int(page) if page is not None else DEFAULT_PAGE_NUMBER 17 | per_page = int(per_page) if per_page is not None else DEFAULT_PAGE_SIZE 18 | return page, per_page, request_args 19 | 20 | 21 | def paginate(query: SQLAlchemy, schema: Schema) -> Dict[str, Any]: 22 | page, per_page, other_request_args = extract_pagination(**request.args) # type: ignore[arg-type] 23 | # Make pagination index 0 based instead of 1 based 24 | if per_page == -1: 25 | per_page = query.count() 26 | page_obj = query.paginate(page=page + 1, per_page=per_page) 27 | endpoint = request.endpoint if request.endpoint is not None else "" 28 | view_args = request.view_args if request.view_args is not None else {} 29 | next_ = url_for( 30 | endpoint, 31 | page=page_obj.next_num - 1 if page_obj.has_next else page_obj.page - 1, 32 | per_page=per_page, 33 | **other_request_args, 34 | **view_args, 35 | ) 36 | prev = url_for( 37 | endpoint, 38 | page=page_obj.prev_num - 1 if page_obj.has_prev else page_obj.page - 1, 39 | per_page=per_page, 40 | **other_request_args, 41 | **view_args, 42 | ) 43 | 44 | return { 45 | "total": page_obj.total, 46 | "pages": page_obj.pages, 47 | "next": next_, 48 | "prev": prev, 49 | "results": schema.dump(page_obj.items), 50 | } 51 | -------------------------------------------------------------------------------- /api/views/schemas/group_memberships.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Any, Dict, Optional 3 | 4 | from marshmallow import Schema, ValidationError, fields, post_load, validate 5 | 6 | 7 | class GroupMemberSchema(Schema): 8 | members = fields.List(fields.String(), dump_only=True) 9 | owners = fields.List(fields.String(), dump_only=True) 10 | 11 | members_to_add = fields.List(fields.String(validate=validate.Length(equal=20)), required=True, load_only=True) 12 | owners_to_add = fields.List(fields.String(validate=validate.Length(equal=20)), required=True, load_only=True) 13 | members_should_expire = fields.List(fields.Int(), required=False, load_only=True) 14 | owners_should_expire = fields.List(fields.Int(), required=False, load_only=True) 15 | members_to_remove = fields.List(fields.String(validate=validate.Length(equal=20)), required=True, load_only=True) 16 | owners_to_remove = fields.List(fields.String(validate=validate.Length(equal=20)), required=True, load_only=True) 17 | created_reason = fields.String(load_only=True, validate=validate.Length(max=1024)) 18 | 19 | @staticmethod 20 | def must_be_in_the_future(data: Optional[datetime]) -> None: 21 | if data and data < datetime.now(): 22 | raise ValidationError("Ended at datetime for add users must be in the future") 23 | 24 | users_added_ending_at = fields.DateTime( 25 | load_only=True, format="rfc822", metadata={"validation": must_be_in_the_future} 26 | ) 27 | 28 | @post_load 29 | def convert_to_utc(self, item: Dict[str, Any], many: bool, **kwargs: Any) -> Dict[str, Any]: 30 | # Ensure the datetime we store in the database is UTC 31 | if "users_added_ending_at" in item: 32 | item["users_added_ending_at"] = item["users_added_ending_at"].astimezone(tz=timezone.utc) 33 | return item 34 | -------------------------------------------------------------------------------- /api/views/schemas/role_memberships.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Any, Dict, Optional 3 | 4 | from marshmallow import Schema, ValidationError, fields, post_load, validate 5 | 6 | 7 | class RoleMemberSchema(Schema): 8 | groups_in_role = fields.List(fields.String(), dump_only=True) 9 | groups_owned_by_role = fields.List(fields.String(), dump_only=True) 10 | 11 | groups_to_add = fields.List(fields.String(validate=validate.Length(equal=20)), required=True, load_only=True) 12 | groups_should_expire = fields.List(fields.Int(), required=False, load_only=True) 13 | owner_groups_to_add = fields.List(fields.String(validate=validate.Length(equal=20)), required=True, load_only=True) 14 | groups_to_remove = fields.List(fields.String(validate=validate.Length(equal=20)), required=True, load_only=True) 15 | owner_groups_should_expire = fields.List(fields.Int(), required=False, load_only=True) 16 | owner_groups_to_remove = fields.List( 17 | fields.String(validate=validate.Length(equal=20)), required=True, load_only=True 18 | ) 19 | created_reason = fields.String(load_only=True, validate=validate.Length(max=1024)) 20 | 21 | @staticmethod 22 | def must_be_in_the_future(data: Optional[datetime]) -> None: 23 | if data and data < datetime.now(): 24 | raise ValidationError("Ended at datetime for add users must be in the future") 25 | 26 | groups_added_ending_at = fields.DateTime( 27 | load_only=True, format="rfc822", metadata={"validation": must_be_in_the_future} 28 | ) 29 | 30 | @post_load 31 | def convert_to_utc(self, item: Dict[str, Any], many: bool, **kwargs: Any) -> Dict[str, Any]: 32 | # Ensure the datetime we store in the database is UTC 33 | if "groups_added_ending_at" in item: 34 | item["groups_added_ending_at"] = item["groups_added_ending_at"].astimezone(tz=timezone.utc) 35 | return item 36 | -------------------------------------------------------------------------------- /examples/plugins/health_check_plugin/README.md: -------------------------------------------------------------------------------- 1 | # Health Check Plugin 2 | 3 | This is an example plugin that demonstrates how to extend Flask CLI commands using plugins. The `health_check_plugin` adds a custom `health` command to the Flask CLI, which performs a health check of the application, including verifying database connectivity. 4 | 5 | ## Overview 6 | 7 | The plugin consists of the following files: 8 | 9 | - **`__init__.py`**: Initializes the plugin by defining an `init_app` function that registers the CLI commands. 10 | - **`cli.py`**: Contains the implementation of the `health` command. 11 | - **`setup.py`**: Defines the plugin's setup configuration and registers the entry point for the CLI command. 12 | 13 | ## Installation 14 | 15 | To install the plugin the App container Dockerfile 16 | 17 | ``` 18 | WORKDIR /app/plugins 19 | ADD ./examples/plugins/health_check_plugin ./health_check_plugin 20 | RUN pip install ./health_check_plugin 21 | 22 | # Reset working directory 23 | WORKDIR /app 24 | ``` 25 | 26 | ## Usage 27 | 28 | After installing the plugin, the `health` command becomes available in the Flask CLI: 29 | 30 | ```bash 31 | flask health 32 | ``` 33 | 34 | This command outputs the application's health status in JSON format, indicating the database connection status and the application version. 35 | 36 | ## Purpose 37 | 38 | This plugin serves as an example of how to extend Flask CLI commands using plugins and entry points. It demonstrates: 39 | 40 | - How to create a custom CLI command in a plugin. 41 | - How to register the command using entry points in `setup.py`. 42 | 43 | By following this example, you can create your own plugins to extend the functionality of your Flask application's CLI in a modular and scalable way. 44 | 45 | ## Files 46 | 47 | - **[`__init__.py`](./__init__.py)**: Plugin initialization code. 48 | - **[`cli.py`](./cli.py)**: Implementation of the `health` CLI command. 49 | - **[`setup.py`](./setup.py)**: Setup script defining the plugin metadata and entry points. 50 | 51 | -------------------------------------------------------------------------------- /api/views/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from api.views.resources.access_request import AccessRequestList, AccessRequestResource 2 | from api.views.resources.app import AppList, AppResource 3 | from api.views.resources.audit import GroupRoleAuditResource, UserGroupAuditResource 4 | from api.views.resources.bug import SentryProxyResource 5 | from api.views.resources.group import GroupAuditResource, GroupList, GroupMemberResource, GroupResource 6 | from api.views.resources.plugin import ( 7 | AppGroupLifecyclePluginAppConfigProperties, 8 | AppGroupLifecyclePluginAppStatusProperties, 9 | AppGroupLifecyclePluginGroupConfigProperties, 10 | AppGroupLifecyclePluginGroupStatusProperties, 11 | AppGroupLifecyclePluginList, 12 | ) 13 | from api.views.resources.role import RoleAuditResource, RoleList, RoleMemberResource, RoleResource 14 | from api.views.resources.role_request import RoleRequestList, RoleRequestResource 15 | from api.views.resources.tag import TagList, TagResource 16 | from api.views.resources.user import UserAuditResource, UserList, UserResource 17 | from api.views.resources.webhook import OktaWebhookResource 18 | 19 | __all__ = [ 20 | "AccessRequestList", 21 | "AccessRequestResource", 22 | "AppGroupLifecyclePluginAppConfigProperties", 23 | "AppGroupLifecyclePluginAppStatusProperties", 24 | "AppGroupLifecyclePluginGroupConfigProperties", 25 | "AppGroupLifecyclePluginGroupStatusProperties", 26 | "AppGroupLifecyclePluginList", 27 | "AppList", 28 | "AppResource", 29 | "GroupAuditResource", 30 | "GroupList", 31 | "GroupMemberResource", 32 | "GroupResource", 33 | "GroupRoleAuditResource", 34 | "OktaWebhookResource", 35 | "RoleAuditResource", 36 | "RoleList", 37 | "RoleMemberResource", 38 | "RoleResource", 39 | "RoleRequestList", 40 | "RoleRequestResource", 41 | "SentryProxyResource", 42 | "TagList", 43 | "TagResource", 44 | "UserAuditResource", 45 | "UserGroupAuditResource", 46 | "UserList", 47 | "UserResource", 48 | ] 49 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import {defineConfig, loadEnv} from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import {sentryVitePlugin} from '@sentry/vite-plugin'; 5 | import {loadAccessConfig} from './src/config/loadAccessConfig'; 6 | 7 | const accessConfig = loadAccessConfig(); 8 | 9 | export default defineConfig(({mode}) => { 10 | // Load env file based on `mode` in the current working directory. 11 | // Process environment variables take precedence over .env files 12 | const env = {...loadEnv(mode, process.cwd(), ''), ...process.env}; 13 | 14 | return { 15 | plugins: [ 16 | react(), 17 | // Only include Sentry plugin in production builds 18 | ...(env.NODE_ENV === 'production' && !!env.SENTRY_AUTH_TOKEN 19 | ? [ 20 | sentryVitePlugin({ 21 | org: env.SENTRY_ORG, 22 | project: env.SENTRY_PROJECT, 23 | authToken: env.SENTRY_AUTH_TOKEN, 24 | sourcemaps: { 25 | assets: './build/**', 26 | filesToDeleteAfterUpload: './build/**/*.map', 27 | }, 28 | release: { 29 | name: env.SENTRY_RELEASE, 30 | }, 31 | }), 32 | ] 33 | : []), 34 | ], 35 | resolve: { 36 | alias: { 37 | '@mui/styled-engine': '@mui/styled-engine-sc', 38 | }, 39 | }, 40 | define: { 41 | ACCESS_CONFIG: accessConfig, 42 | APP_NAME: JSON.stringify(env.APP_NAME || 'Access'), 43 | REQUIRE_DESCRIPTIONS: env.REQUIRE_DESCRIPTIONS?.toLowerCase() === 'true', 44 | }, 45 | server: { 46 | port: 3000, 47 | }, 48 | build: { 49 | outDir: 'build', 50 | sourcemap: env.NODE_ENV === 'development' || (env.NODE_ENV === 'production' && !!env.SENTRY_AUTH_TOKEN), // Enable source maps for Sentry 51 | }, 52 | publicDir: 'public', 53 | test: { 54 | globals: true, 55 | environment: 'jsdom', 56 | setupFiles: './src/setupTests.ts', 57 | }, 58 | }; 59 | }); 60 | -------------------------------------------------------------------------------- /examples/plugins/health_check_plugin/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | from flask.cli import with_appcontext 5 | from sqlalchemy import text 6 | 7 | 8 | @click.command("health") 9 | @with_appcontext 10 | def health_command() -> None: 11 | """Displays application database health and metrics in JSON format.""" 12 | from flask import current_app, json 13 | 14 | from api.extensions import db 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | try: 19 | # Perform a simple database health check using SQLAlchemy 20 | db.session.execute(text("SELECT 1")) 21 | db_status = "connected" 22 | error = None 23 | logger.info("Database connection successful.") 24 | 25 | # Retrieve all table names and their row counts 26 | tables_query = text(""" 27 | SELECT table_name 28 | FROM information_schema.tables 29 | WHERE table_schema = 'public'; 30 | """) 31 | tables = db.session.execute(tables_query).fetchall() 32 | 33 | table_sizes = {} 34 | for table in tables: 35 | table_name = table[0] 36 | row_count_query = text(f"SELECT COUNT(*) FROM {table_name}") 37 | row_count = db.session.execute(row_count_query).scalar() 38 | table_sizes[table_name] = row_count 39 | 40 | except Exception as e: 41 | db_status = "disconnected" 42 | error = str(e) 43 | table_sizes = {} 44 | logger.error(f"Database connection error: {error}") 45 | 46 | # Prepare the health status response 47 | status = { 48 | "status": "ok" if db_status == "connected" else "error", 49 | "database": db_status, 50 | "tables": table_sizes, 51 | "version": current_app.config.get("APP_VERSION", "Not Defined"), 52 | **({"error": error} if error else {}), 53 | } 54 | 55 | # Log the health status 56 | logger.info(f"Health status: {status}") 57 | 58 | # Output the health status as a JSON string 59 | click.echo(json.dumps(status)) 60 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; 3 | import {createRoot} from 'react-dom/client'; 4 | import {BrowserRouter} from 'react-router-dom'; 5 | import {AdapterDayjs} from '@mui/x-date-pickers/AdapterDayjs'; 6 | import {LocalizationProvider} from '@mui/x-date-pickers'; 7 | import * as Sentry from '@sentry/react'; 8 | 9 | import App from './App'; 10 | import Error from './pages/Error'; 11 | 12 | import {appName} from './config/accessConfig'; 13 | 14 | document.title = appName; 15 | const metaDesc = document.querySelector('meta[name="description"]'); 16 | if (metaDesc) metaDesc.setAttribute('content', `${appName}!`); 17 | 18 | const queryClient = new QueryClient({ 19 | defaultOptions: { 20 | queries: {retry: false}, 21 | }, 22 | }); 23 | 24 | // Cache plugin metadata indefinitely since it doesn't change while the app is running 25 | queryClient.setQueryDefaults(['api', 'plugins'], { 26 | staleTime: Infinity, 27 | cacheTime: Infinity, 28 | refetchOnMount: false, 29 | refetchOnWindowFocus: false, 30 | refetchOnReconnect: false, 31 | }); 32 | 33 | if (['production', 'staging'].includes(import.meta.env.MODE)) { 34 | // Use a placeholder DSN as we'll be using the tunnel to proxy all Sentry React errors 35 | Sentry.init({ 36 | dsn: 'https://user@example.ingest.sentry.io/1234567', 37 | release: import.meta.env.VITE_SENTRY_RELEASE, 38 | integrations: [Sentry.replayIntegration()], 39 | replaysSessionSampleRate: 0, 40 | replaysOnErrorSampleRate: 1.0, 41 | tunnel: '/api/bugs/sentry', 42 | }); 43 | } 44 | 45 | createRoot(document.getElementById('root')!).render( 46 | 47 | } showDialog> 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | , 57 | ); 58 | -------------------------------------------------------------------------------- /examples/plugins/conditional_access/conditional_access.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import logging 4 | import os 5 | from typing import List, Optional 6 | 7 | import pluggy 8 | 9 | from api.models import AccessRequest, OktaGroup, OktaUser, Tag 10 | from api.plugins import ConditionalAccessResponse 11 | 12 | request_hook_impl = pluggy.HookimplMarker("access_conditional_access") 13 | logger = logging.getLogger(__name__) 14 | 15 | # Constants for auto-approval conditions (not required if you only want to use the Auto-Approval TAG) 16 | # Example of how to set this in an environment variable in your .env.production file: 17 | # AUTO_APPROVED_GROUP_NAMES="Group1,Group2,Group3" 18 | AUTO_APPROVED_GROUP_NAMES = ( 19 | os.getenv("AUTO_APPROVED_GROUP_NAMES", "").split(",") if os.getenv("AUTO_APPROVED_GROUP_NAMES") else [] 20 | ) 21 | 22 | # Example of how to set this in an environment variable in your .env.production file: 23 | # AUTO_APPROVED_TAG_NAMES="Tag1,Tag2,Tag3" 24 | AUTO_APPROVED_TAG_NAMES = os.getenv("AUTO_APPROVED_TAG_NAMES", "Auto-Approve").split(",") 25 | 26 | 27 | @request_hook_impl 28 | def access_request_created( 29 | access_request: AccessRequest, group: OktaGroup, group_tags: List[Tag], requester: OktaUser 30 | ) -> Optional[ConditionalAccessResponse]: 31 | """Auto-approve memberships to the Auto-Approved-Group group""" 32 | 33 | if not access_request.request_ownership: 34 | # Check either group name or tag for auto-approval 35 | is_auto_approved_name = group.name in AUTO_APPROVED_GROUP_NAMES 36 | is_auto_approved_tag = any(tag.name in AUTO_APPROVED_TAG_NAMES for tag in group_tags) 37 | 38 | if is_auto_approved_name or is_auto_approved_tag: 39 | logger.info(f"Auto-approving access request {access_request.id} to group {group.name}") 40 | return ConditionalAccessResponse( 41 | approved=True, reason="Group membership auto-approved", ending_at=access_request.request_ending_at 42 | ) 43 | 44 | logger.info(f"Access request {access_request.id} to group {group.name} requires manual approval") 45 | 46 | return None 47 | -------------------------------------------------------------------------------- /examples/kubernetes/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | labels: 6 | app: access 7 | name: access 8 | namespace: access 9 | spec: 10 | replicas: 3 11 | selector: 12 | matchLabels: 13 | app: access 14 | template: 15 | metadata: 16 | labels: 17 | app: access 18 | spec: 19 | affinity: 20 | podAntiAffinity: 21 | requiredDuringSchedulingIgnoredDuringExecution: 22 | - labelSelector: 23 | matchExpressions: 24 | - key: app 25 | operator: In 26 | values: 27 | - access 28 | topologyKey: kubernetes.io/hostname 29 | containers: 30 | - args: 31 | - gunicorn 32 | - -w 33 | - '4' 34 | - -t 35 | - '600' 36 | - -b 37 | - :3000 38 | - --access-logfile 39 | - '-' 40 | - api.wsgi:app 41 | env: # See "Production Setup" in the README for more details on configuring these environment variables 42 | - name: FLASK_ENV 43 | value: production 44 | - name: OKTA_DOMAIN 45 | value: mydomain.okta.com # Replace with your Okta domain 46 | - name: DATABASE_URI 47 | value: postgresql+pg8000:// # Replace with your database URI 48 | - name: USER_DISPLAY_CUSTOM_ATTRIBUTES 49 | value: Title,Department,Work Location # Replace with the Custom Okta User Attributes you want to display 50 | - name: OKTA_API_TOKEN 51 | valueFrom: 52 | secretKeyRef: 53 | key: OKTA_API_TOKEN 54 | name: access-secrets 55 | image: access # Replace with reference to a Docker image build of access in your container registry 56 | name: access 57 | ports: 58 | - containerPort: 3000 59 | livenessProbe: 60 | httpGet: 61 | path: /api/healthz 62 | port: 3000 63 | serviceAccountName: access 64 | -------------------------------------------------------------------------------- /api/views/schemas/access_requests.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Any, Dict, Optional 3 | 4 | from marshmallow import Schema, ValidationError, fields, post_load, validate 5 | 6 | 7 | class CreateAccessRequestSchema(Schema): 8 | group_id = fields.String(validate=validate.Length(equal=20), required=True, load_only=True) 9 | group_owner = fields.Boolean(load_default=False, load_only=True) 10 | reason = fields.String(validate=validate.Length(max=1024), load_only=True) 11 | 12 | @staticmethod 13 | def must_be_in_the_future(data: Optional[datetime]) -> None: 14 | if data and data < datetime.now(): 15 | raise ValidationError("Ended at datetime for access request approval must be in the future") 16 | 17 | ending_at = fields.DateTime( 18 | load_only=True, 19 | format="rfc822", 20 | metadata={"validation": must_be_in_the_future}, 21 | ) 22 | 23 | @post_load 24 | def convert_to_utc(self, item: Dict[str, Any], many: bool, **kwargs: Any) -> Dict[str, Any]: 25 | # Ensure the datetime we store in the database is UTC 26 | if "ending_at" in item: 27 | item["ending_at"] = item["ending_at"].astimezone(tz=timezone.utc) 28 | return item 29 | 30 | 31 | class ResolveAccessRequestSchema(Schema): 32 | approved = fields.Boolean(required=True, load_only=True) 33 | reason = fields.String(load_only=True, validate=validate.Length(max=1024)) 34 | 35 | @staticmethod 36 | def must_be_in_the_future(data: Optional[datetime]) -> None: 37 | if data and data < datetime.now(): 38 | raise ValidationError("Ended at datetime for access request approval must be in the future") 39 | 40 | ending_at = fields.DateTime( 41 | load_only=True, 42 | format="rfc822", 43 | metadata={"validation": must_be_in_the_future}, 44 | ) 45 | 46 | @post_load 47 | def convert_to_utc(self, item: Dict[str, Any], many: bool, **kwargs: Any) -> Dict[str, Any]: 48 | # Ensure the datetime we store in the database is UTC 49 | if "ending_at" in item: 50 | item["ending_at"] = item["ending_at"].astimezone(tz=timezone.utc) 51 | return item 52 | -------------------------------------------------------------------------------- /api/log_filters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import Any, Dict 4 | 5 | from gunicorn.glogging import Logger as GunicornLogger 6 | 7 | 8 | class TokenSanitizingFilter(logging.Filter): 9 | """Filter that redacts sensitive token information from Flask application logs. 10 | 11 | Note: This filter handles internal application logs. For HTTP access logs, 12 | see gunicorn_logging.py which provides specialized handling for those. 13 | """ 14 | 15 | def filter(self, record: logging.LogRecord) -> bool: 16 | if hasattr(record, "msg") and isinstance(record.msg, str): 17 | msg = record.msg 18 | 19 | # Check for token logging pattern from flask_oidc 20 | if "Could not refresh token" in msg and "{" in msg: 21 | # Replace the entire token dictionary with a placeholder 22 | msg = re.sub(r"Could not refresh token (\{.*?\})", "Could not refresh token {REDACTED_TOKEN_DATA}", msg) 23 | 24 | # This provides defense-in-depth alongside the Gunicorn logger 25 | if "code=" in msg: 26 | msg = re.sub(r'(code=)[^&"\s]*([&"\s]|$)', r"\1[REDACTED_AUTH_CODE]\2", msg) 27 | 28 | record.msg = msg 29 | 30 | return True 31 | 32 | 33 | # Ignore mypy error as GunicornLogger lacks proper type annotations in gunicorn stubs 34 | class RedactingGunicornLogger(GunicornLogger): # type: ignore[misc] 35 | """ 36 | Gunicorn logger that strips query strings from /oidc/authorize access logs. 37 | """ 38 | 39 | def access(self, resp: Any, req: Any, environ: Dict[str, Any], request_time: float) -> None: 40 | path = environ.get("PATH_INFO", "") 41 | query = environ.get("QUERY_STRING", "") 42 | 43 | if path.startswith("/oidc/authorize"): 44 | # Override WSGI variable used by Gunicorn's access log formatter 45 | environ["RAW_URI"] = f"{path}?[REDACTED]" 46 | else: 47 | # Optional: Set RAW_URI for other paths to preserve default behavior 48 | # so Gunicorn doesn't construct it from PATH_INFO + QUERY_STRING 49 | environ["RAW_URI"] = f"{path}?{query}" if query else path 50 | 51 | super().access(resp, req, environ, request_time) 52 | -------------------------------------------------------------------------------- /api/models/app_group.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from api.extensions import db 4 | from api.models.core_models import App, AppGroup, OktaGroup, OktaUser, OktaUserGroupMember 5 | 6 | 7 | def get_app_managers(app_id: str) -> List[OktaUser]: 8 | """Returns the users that can manage members of the app""" 9 | owner_app_groups = ( 10 | AppGroup.query.filter(OktaGroup.deleted_at.is_(None)) 11 | .filter(AppGroup.app_id == app_id) 12 | .filter(AppGroup.is_owner.is_(True)) 13 | ) 14 | 15 | if owner_app_groups.count() > 0: 16 | return ( 17 | OktaUser.query.join(OktaUser.all_group_memberships_and_ownerships) 18 | .filter(OktaUserGroupMember.group_id.in_([ag.id for ag in owner_app_groups])) 19 | .filter(OktaUserGroupMember.is_owner.is_(True)) 20 | .filter( 21 | db.or_( 22 | OktaUserGroupMember.ended_at.is_(None), 23 | OktaUserGroupMember.ended_at > db.func.now(), 24 | ) 25 | ) 26 | .all() 27 | ) 28 | 29 | return [] 30 | 31 | 32 | def get_access_owners() -> List[OktaUser]: 33 | """Returns the access super admins that are members of the owners group""" 34 | 35 | access_app = App.query.filter(App.deleted_at.is_(None)).filter(App.name == App.ACCESS_APP_RESERVED_NAME).first() 36 | 37 | owner_app_groups = ( 38 | AppGroup.query.filter(OktaGroup.deleted_at.is_(None)) 39 | .filter(AppGroup.app_id == access_app.id) 40 | .filter(AppGroup.is_owner.is_(True)) 41 | ) 42 | 43 | if owner_app_groups.count() > 0: 44 | return ( 45 | OktaUser.query.join(OktaUser.all_group_memberships_and_ownerships) 46 | .filter(OktaUserGroupMember.group_id.in_([ag.id for ag in owner_app_groups])) 47 | .filter(OktaUserGroupMember.is_owner.is_(False)) 48 | .filter( 49 | db.or_( 50 | OktaUserGroupMember.ended_at.is_(None), 51 | OktaUserGroupMember.ended_at > db.func.now(), 52 | ) 53 | ) 54 | .all() 55 | ) 56 | 57 | return [] 58 | 59 | 60 | def app_owners_group_description(app_name: str) -> str: 61 | return f"Owners of the {app_name} application" 62 | -------------------------------------------------------------------------------- /api/views/schemas/role_requests.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Any, Dict, Optional 3 | 4 | from marshmallow import Schema, ValidationError, fields, post_load, validate 5 | 6 | 7 | class CreateRoleRequestSchema(Schema): 8 | role_id = fields.String(validate=validate.Length(equal=20), required=True, load_only=True) 9 | group_id = fields.String(validate=validate.Length(equal=20), required=True, load_only=True) 10 | group_owner = fields.Boolean(load_default=False, load_only=True) 11 | reason = fields.String(validate=validate.Length(max=1024), load_only=True) 12 | 13 | @staticmethod 14 | def must_be_in_the_future(data: Optional[datetime]) -> None: 15 | if data and data < datetime.now(): 16 | raise ValidationError("Ended at datetime for access request approval must be in the future") 17 | 18 | ending_at = fields.DateTime( 19 | load_only=True, 20 | format="rfc822", 21 | metadata={"validation": must_be_in_the_future}, 22 | ) 23 | 24 | @post_load 25 | def convert_to_utc(self, item: Dict[str, Any], many: bool, **kwargs: Any) -> Dict[str, Any]: 26 | # Ensure the datetime we store in the database is UTC 27 | if "ending_at" in item: 28 | item["ending_at"] = item["ending_at"].astimezone(tz=timezone.utc) 29 | return item 30 | 31 | 32 | class ResolveRoleRequestSchema(Schema): 33 | approved = fields.Boolean(required=True, load_only=True) 34 | reason = fields.String(load_only=True, validate=validate.Length(max=1024)) 35 | 36 | @staticmethod 37 | def must_be_in_the_future(data: Optional[datetime]) -> None: 38 | if data and data < datetime.now(): 39 | raise ValidationError("Ended at datetime for access request approval must be in the future") 40 | 41 | ending_at = fields.DateTime( 42 | load_only=True, 43 | format="rfc822", 44 | metadata={"validation": must_be_in_the_future}, 45 | ) 46 | 47 | @post_load 48 | def convert_to_utc(self, item: Dict[str, Any], many: bool, **kwargs: Any) -> Dict[str, Any]: 49 | # Ensure the datetime we store in the database is UTC 50 | if "ending_at" in item: 51 | item["ending_at"] = item["ending_at"].astimezone(tz=timezone.utc) 52 | return item 53 | -------------------------------------------------------------------------------- /src/components/actions/TablePaginationActions.tsx: -------------------------------------------------------------------------------- 1 | import {useTheme} from '@mui/material/styles'; 2 | 3 | import Box from '@mui/material/Box'; 4 | import IconButton from '@mui/material/IconButton'; 5 | import FirstPageIcon from '@mui/icons-material/FirstPage'; 6 | import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft'; 7 | import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; 8 | import LastPageIcon from '@mui/icons-material/LastPage'; 9 | 10 | interface TablePaginationActionsProps { 11 | count: number; 12 | page: number; 13 | rowsPerPage: number; 14 | onPageChange: (event: React.MouseEvent, newPage: number) => void; 15 | } 16 | 17 | export default function TablePaginationActions(props: TablePaginationActionsProps) { 18 | const theme = useTheme(); 19 | const {count, page, rowsPerPage, onPageChange} = props; 20 | 21 | const handleFirstPageButtonClick = (event: React.MouseEvent) => { 22 | onPageChange(event, 0); 23 | }; 24 | 25 | const handleBackButtonClick = (event: React.MouseEvent) => { 26 | onPageChange(event, page - 1); 27 | }; 28 | 29 | const handleNextButtonClick = (event: React.MouseEvent) => { 30 | onPageChange(event, page + 1); 31 | }; 32 | 33 | const handleLastPageButtonClick = (event: React.MouseEvent) => { 34 | onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | = Math.ceil(count / rowsPerPage) - 1} 48 | aria-label="next page"> 49 | 50 | 51 | = Math.ceil(count / rowsPerPage) - 1} 54 | aria-label="last page"> 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /api/views/resources/metrics.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import request 4 | from flask.typing import ResponseReturnValue 5 | from flask_apispec import MethodResource 6 | from marshmallow import ValidationError 7 | 8 | from api.apispec import FlaskApiSpecDecorators 9 | from api.plugins.metrics_reporter import get_metrics_reporter_hook 10 | from api.views.schemas import MetricsSchema 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class MetricsResource(MethodResource): 16 | @FlaskApiSpecDecorators.request_schema(MetricsSchema) 17 | def post(self) -> ResponseReturnValue: 18 | try: 19 | metrics_data = MetricsSchema().load(request.get_json()) 20 | except ValidationError as e: 21 | return {"errors": e.messages}, 400 22 | 23 | metric_type = metrics_data["type"] 24 | data = metrics_data["data"] 25 | 26 | metrics_hook = get_metrics_reporter_hook() 27 | 28 | tags = {"source": "frontend", "metric_type": metric_type} 29 | 30 | if "tags" in data: 31 | tags.update(data["tags"]) 32 | 33 | metric_name = data.get("name", f"frontend.{metric_type}") 34 | 35 | if metric_type == "counter": 36 | value = data.get("value", 1.0) 37 | metrics_hook.record_counter(metric_name=metric_name, value=value, tags=tags) 38 | 39 | elif metric_type == "gauge": 40 | value = data.get("value", 0.0) 41 | metrics_hook.record_gauge(metric_name=metric_name, value=value, tags=tags) 42 | 43 | elif metric_type == "histogram": 44 | value = data.get("value", 0.0) 45 | buckets = data.get("buckets", None) 46 | metrics_hook.record_histogram(metric_name=metric_name, value=value, tags=tags, buckets=buckets) 47 | 48 | elif metric_type == "timing": 49 | # Special handling for timing metrics 50 | value = data.get("duration", 0.0) 51 | metrics_hook.record_histogram( 52 | metric_name=f"{metric_name}.duration_ms", value=value, tags={**tags, "unit": "ms"} 53 | ) 54 | 55 | else: 56 | return {"error": f"Unknown metric type: {metric_type}"}, 400 57 | 58 | logger.info(f"Recorded {metric_type} metric: {metric_name} = {value} with tags {tags}") 59 | 60 | return "", 200 61 | -------------------------------------------------------------------------------- /src/components/TableTopBar.tsx: -------------------------------------------------------------------------------- 1 | import {Launch} from '@mui/icons-material'; 2 | import {Autocomplete, AutocompleteProps, Box, Grid, IconButton, Stack, TextField, Typography} from '@mui/material'; 3 | 4 | import * as React from 'react'; 5 | import {useNavigate} from 'react-router-dom'; 6 | 7 | export function renderUserOption(props: React.HTMLAttributes, option: any) { 8 | const [displayName, email] = option.split(';'); 9 | return ( 10 |
  • 11 | 12 | 13 | {displayName} 14 | 15 | {email} 16 | 17 | 18 | 19 |
  • 20 | ); 21 | } 22 | 23 | export function TableTopBarAutocomplete({ 24 | defaultValue, 25 | filterOptions = (x) => x, 26 | ...restProps 27 | }: Omit, 'renderInput'>) { 28 | return ( 29 | } 37 | {...restProps} 38 | /> 39 | ); 40 | } 41 | 42 | interface TableTopBarProps { 43 | title: string; 44 | link?: string; 45 | children?: React.ReactNode; 46 | } 47 | 48 | export default function TableTopBar({title, link, children}: TableTopBarProps) { 49 | const navigate = useNavigate(); 50 | return ( 51 | 60 | 61 | 62 | {title} 63 | 64 | {link != null && ( 65 | navigate(link)}> 66 | 67 | 68 | )} 69 | 70 | 71 | {children} 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /tests/test_okta_retries.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Tuple 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from pytest_mock import MockerFixture 6 | 7 | from api.services.okta_service import REQUEST_MAX_RETRIES, RETRIABLE_STATUS_CODES, OktaService 8 | from tests.factories import UserFactory 9 | 10 | 11 | def mock_get_user(mocker: MockerFixture, status_code: int) -> Mock: 12 | mock_response_object = Mock() 13 | mock_response_object.configure_mock(**{"get_status_code.return_value": status_code}) 14 | if status_code < 400: # success 15 | mock_response: Tuple[Any, Any, Optional[Exception]] = (UserFactory(), mock_response_object, None) 16 | else: # error 17 | mock_response = (None, mock_response_object, Exception()) 18 | return mocker.patch("okta.client.Client.get_user", return_value=mock_response) 19 | 20 | 21 | @pytest.fixture 22 | def okta_service() -> OktaService: 23 | service = OktaService() 24 | service.initialize("fake.domain", "fake.token") 25 | return service 26 | 27 | 28 | @pytest.fixture 29 | def mock_sleep(mocker: MockerFixture) -> Mock: 30 | return mocker.patch("asyncio.sleep") 31 | 32 | 33 | @pytest.mark.parametrize("status_code", RETRIABLE_STATUS_CODES) 34 | def test_retry_logic_error_response_retriable( 35 | mocker: MockerFixture, mock_sleep: Mock, okta_service: OktaService, status_code: int 36 | ) -> None: 37 | mocked_request = mock_get_user(mocker, status_code) 38 | 39 | with pytest.raises(Exception): 40 | okta_service.get_user("okta_id") 41 | assert mocked_request.call_count == 1 + REQUEST_MAX_RETRIES 42 | assert mock_sleep.call_count == REQUEST_MAX_RETRIES 43 | 44 | 45 | def test_retry_logic_error_response_non_retriable( 46 | mocker: MockerFixture, mock_sleep: Mock, okta_service: OktaService 47 | ) -> None: 48 | mocked_request = mock_get_user(mocker, 400) 49 | 50 | with pytest.raises(Exception): 51 | okta_service.get_user("okta_id") 52 | assert mocked_request.call_count == 1 53 | assert mock_sleep.call_count == 0 54 | 55 | 56 | def test_retry_logic_no_error(mocker: MockerFixture, mock_sleep: Mock, okta_service: OktaService) -> None: 57 | mocked_request = mock_get_user(mocker, 200) 58 | 59 | okta_service.get_user("okta_id") 60 | assert mocked_request.call_count == 1 61 | assert mock_sleep.call_count == 0 62 | -------------------------------------------------------------------------------- /api/views/plugins_views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from api.extensions import Api, docs 4 | from api.views.resources import ( 5 | AppGroupLifecyclePluginAppConfigProperties, 6 | AppGroupLifecyclePluginAppStatusProperties, 7 | AppGroupLifecyclePluginGroupConfigProperties, 8 | AppGroupLifecyclePluginGroupStatusProperties, 9 | AppGroupLifecyclePluginList, 10 | ) 11 | 12 | bp_name = "api-plugins" 13 | bp_url_prefix = "/api/plugins" 14 | bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) 15 | 16 | api = Api(bp) 17 | 18 | api.add_resource(AppGroupLifecyclePluginList, "/app-group-lifecycle", endpoint="app_group_lifecycle_plugins") 19 | api.add_resource( 20 | AppGroupLifecyclePluginAppConfigProperties, 21 | "/app-group-lifecycle//app-config-props", 22 | endpoint="app_group_lifecycle_plugin_app_config_props", 23 | ) 24 | api.add_resource( 25 | AppGroupLifecyclePluginGroupConfigProperties, 26 | "/app-group-lifecycle//group-config-props", 27 | endpoint="app_group_lifecycle_plugin_group_config_props", 28 | ) 29 | api.add_resource( 30 | AppGroupLifecyclePluginAppStatusProperties, 31 | "/app-group-lifecycle//app-status-props", 32 | endpoint="app_group_lifecycle_plugin_app_status_props", 33 | ) 34 | api.add_resource( 35 | AppGroupLifecyclePluginGroupStatusProperties, 36 | "/app-group-lifecycle//group-status-props", 37 | endpoint="app_group_lifecycle_plugin_group_status_props", 38 | ) 39 | 40 | 41 | def register_docs() -> None: 42 | docs.register(AppGroupLifecyclePluginList, blueprint=bp_name, endpoint="app_group_lifecycle_plugins") 43 | docs.register( 44 | AppGroupLifecyclePluginAppConfigProperties, 45 | blueprint=bp_name, 46 | endpoint="app_group_lifecycle_plugin_app_config_props", 47 | ) 48 | docs.register( 49 | AppGroupLifecyclePluginGroupConfigProperties, 50 | blueprint=bp_name, 51 | endpoint="app_group_lifecycle_plugin_group_config_props", 52 | ) 53 | docs.register( 54 | AppGroupLifecyclePluginAppStatusProperties, 55 | blueprint=bp_name, 56 | endpoint="app_group_lifecycle_plugin_app_status_props", 57 | ) 58 | docs.register( 59 | AppGroupLifecyclePluginGroupStatusProperties, 60 | blueprint=bp_name, 61 | endpoint="app_group_lifecycle_plugin_group_status_props", 62 | ) 63 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build Arg on whether to push the sentry release or not 2 | # Default is false as it requires mounting a .sentryclirc secret file 3 | ARG PUSH_SENTRY_RELEASE="false" 4 | 5 | # Build step #1: build the React front end 6 | FROM node:22-alpine AS build-step 7 | WORKDIR /app 8 | ENV PATH=/app/node_modules/.bin:$PATH 9 | COPY index.html package.json package-lock.json tsconfig.json tsconfig.paths.json vite.config.ts .env.production* ./ 10 | COPY ./src ./src 11 | COPY ./public ./public 12 | COPY ./config ./config 13 | 14 | RUN npm install 15 | RUN touch .env.production 16 | # Set Vite environment variables 17 | ENV VITE_API_SERVER_URL="" 18 | # Set Sentry plugin environment variables for production build 19 | ENV NODE_ENV=production 20 | RUN npm run build 21 | 22 | # Optional build step #2: upload source maps to Sentry 23 | FROM build-step AS sentry 24 | ARG SENTRY_RELEASE="" 25 | ENV SENTRY_RELEASE=$SENTRY_RELEASE 26 | # Use secret mount for SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT 27 | RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ 28 | --mount=type=secret,id=SENTRY_ORG \ 29 | --mount=type=secret,id=SENTRY_PROJECT \ 30 | SENTRY_AUTH_TOKEN=$(cat /run/secrets/SENTRY_AUTH_TOKEN) \ 31 | SENTRY_ORG=$(cat /run/secrets/SENTRY_ORG) \ 32 | SENTRY_PROJECT=$(cat /run/secrets/SENTRY_PROJECT) \ 33 | npm run build 34 | # Source maps are automatically uploaded and deleted by Vite Sentry plugin during build 35 | RUN touch sentry 36 | 37 | # Build step #3: build the API with the client as static files 38 | FROM python:3.13 AS false 39 | ARG SENTRY_RELEASE="" 40 | WORKDIR /app 41 | COPY --from=build-step /app/build ./build 42 | 43 | RUN mkdir ./api && mkdir ./migrations 44 | COPY requirements.txt api/ ./api/ 45 | COPY migrations/ ./migrations/ 46 | COPY ./config ./config 47 | RUN pip install -r ./api/requirements.txt 48 | 49 | # Build an image that includes the optional sentry release push build step 50 | FROM false AS true 51 | COPY --from=sentry /app/sentry ./sentry 52 | 53 | # Final build step: copy the API and the client from the previous steps 54 | # Choose whether to include the sentry release push build step or not 55 | FROM ${PUSH_SENTRY_RELEASE} 56 | 57 | ENV FLASK_ENV=production 58 | ENV FLASK_APP=api.app:create_app 59 | ENV SENTRY_RELEASE=$SENTRY_RELEASE 60 | 61 | EXPOSE 3000 62 | 63 | CMD ["gunicorn", "-w", "4", "-t", "600", "-b", ":3000", "--access-logfile", "-", "--logger-class", "api.log_filters.RedactingGunicornLogger", "api.wsgi:app"] 64 | -------------------------------------------------------------------------------- /examples/plugins/datadog_metrics_reporter/README.md: -------------------------------------------------------------------------------- 1 | # Discord Access Datadog Metrics Plugin 2 | 3 | This plugin integrates Discord access metrics with Datadog, allowing users to track and monitor access request patterns, approval rates, and system health metrics. 4 | 5 | ## Installation 6 | 7 | Update the Dockerfile used to build the App container includes the following section for installing the metrics plugin before starting gunicorn: 8 | 9 | ```dockerfile 10 | # Add the specific plugins and install metrics 11 | WORKDIR /app/plugins 12 | ADD ./examples/plugins/metrics_reporter ./metrics_reporter 13 | RUN pip install -r ./metrics_reporter/requirements.txt && pip install ./metrics_reporter 14 | 15 | # Reset working directory 16 | WORKDIR /app 17 | 18 | ENV FLASK_ENV production 19 | ENV FLASK_APP api.app:create_app 20 | ENV SENTRY_RELEASE $SENTRY_RELEASE 21 | 22 | EXPOSE 3000 23 | 24 | CMD ["gunicorn", "-w", "4", "-t", "600", "-b", ":3000", "--access-logfile", "-", "api.wsgi:app"] 25 | ``` 26 | 27 | ## Build the Docker image, run and test 28 | 29 | You may use the original Discord Access container build processes from the primary README.md: 30 | ```bash 31 | docker compose up --build 32 | ``` 33 | 34 | Verify metrics collection is working as designed. 35 | 36 | ## Configuration 37 | 38 | The following environment variables need to be configured for the Datadog metrics plugin to work properly: 39 | 40 | ### Required Environment Variables 41 | 42 | - `FLASK_ENV`: Application environment (e.g., `production`, `staging`, `development`) 43 | - Used to determine the environment tag for metrics 44 | - Maps to: `production` → `prd`, `staging` → `stg`, other → `dev` 45 | 46 | ### Optional Environment Variables 47 | 48 | - `STATSD_HOST_IP`: IP address of the StatsD/DogStatsD server 49 | - If not set, falls back to `DD_AGENT_HOST` 50 | - Default: `127.0.0.1` 51 | 52 | - `DD_AGENT_HOST`: Datadog Agent host address 53 | - Used when `STATSD_HOST_IP` is not provided 54 | - Default: `127.0.0.1` 55 | 56 | - `DD_DOGSTATSD_PORT`: Port for DogStatsD communication 57 | - Default: `8125` 58 | 59 | ## Development 60 | 61 | To contribute to the development of this plugin, please follow the standard Git workflow: 62 | 63 | 1. Fork the repository. 64 | 2. Create a new branch for your feature or bug fix. 65 | 3. Make your changes and commit them. 66 | 4. Push your branch and create a pull request. 67 | 68 | ## License 69 | 70 | This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. 71 | 72 | -------------------------------------------------------------------------------- /api/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | import pluggy 2 | 3 | from api.plugins.app_group_lifecycle import ( 4 | AppGroupLifecyclePluginConfigProperty, 5 | AppGroupLifecyclePluginFilteringError, 6 | AppGroupLifecyclePluginMetadata, 7 | AppGroupLifecyclePluginSpec, 8 | AppGroupLifecyclePluginStatusProperty, 9 | app_group_lifecycle_plugin_name, 10 | get_app_group_lifecycle_hook, 11 | get_app_group_lifecycle_plugin_app_config_properties, 12 | get_app_group_lifecycle_plugin_app_status_properties, 13 | get_app_group_lifecycle_plugin_group_config_properties, 14 | get_app_group_lifecycle_plugin_group_status_properties, 15 | get_app_group_lifecycle_plugin_to_invoke, 16 | get_app_group_lifecycle_plugins, 17 | get_config_value, 18 | get_status_value, 19 | merge_app_lifecycle_plugin_data, 20 | set_status_value, 21 | validate_app_group_lifecycle_plugin_app_config, 22 | validate_app_group_lifecycle_plugin_group_config, 23 | ) 24 | from api.plugins.conditional_access import ConditionalAccessResponse, get_conditional_access_hook 25 | from api.plugins.notifications import get_notification_hook 26 | 27 | app_group_lifecycle_hook_impl = pluggy.HookimplMarker("access_app_group_lifecycle") 28 | conditional_access_hook_impl = pluggy.HookimplMarker("access_conditional_access") 29 | notification_hook_impl = pluggy.HookimplMarker("access_notifications") 30 | 31 | __all__ = [ 32 | # App Group Lifecycle Plugin 33 | "app_group_lifecycle_plugin_name", 34 | "AppGroupLifecyclePluginConfigProperty", 35 | "AppGroupLifecyclePluginFilteringError", 36 | "AppGroupLifecyclePluginMetadata", 37 | "AppGroupLifecyclePluginSpec", 38 | "AppGroupLifecyclePluginStatusProperty", 39 | "get_app_group_lifecycle_hook", 40 | "get_app_group_lifecycle_plugins", 41 | "get_app_group_lifecycle_plugin_to_invoke", 42 | "get_app_group_lifecycle_plugin_app_config_properties", 43 | "get_app_group_lifecycle_plugin_group_config_properties", 44 | "get_app_group_lifecycle_plugin_app_status_properties", 45 | "get_app_group_lifecycle_plugin_group_status_properties", 46 | "get_config_value", 47 | "get_status_value", 48 | "merge_app_lifecycle_plugin_data", 49 | "set_status_value", 50 | "validate_app_group_lifecycle_plugin_app_config", 51 | "validate_app_group_lifecycle_plugin_group_config", 52 | "app_group_lifecycle_hook_impl", 53 | # Conditional Access Plugin 54 | "ConditionalAccessResponse", 55 | "get_conditional_access_hook", 56 | "conditional_access_hook_impl", 57 | # Notifications Plugin 58 | "get_notification_hook", 59 | "notification_hook_impl", 60 | ] 61 | -------------------------------------------------------------------------------- /examples/plugins/notifications_slack/README.md: -------------------------------------------------------------------------------- 1 | # Discord Access Slack Notifications Plugin 2 | 3 | This plugin integrates Discord access notifications with Slack, allowing users to receive updates and alerts regarding their access requests and expirations directly in Slack. 4 | 5 | ## Installation 6 | 7 | Update the Dockerfile used to build the App container includes the following section for installing the notifications plugin before starting gunicorn: 8 | ```dockerfile 9 | # Add the specific plugins and install notifications 10 | WORKDIR /app/plugins 11 | ADD ./examples/plugins/notifications_slack ./notifications_slack 12 | RUN pip install -r ./notifications_slack/requirements.txt && pip install ./notifications_slack 13 | 14 | # Reset working directory 15 | WORKDIR /app 16 | 17 | ENV FLASK_ENV production 18 | ENV FLASK_APP api.app:create_app 19 | ENV SENTRY_RELEASE $SENTRY_RELEASE 20 | 21 | EXPOSE 3000 22 | 23 | CMD ["gunicorn", "-w", "4", "-t", "600", "-b", ":3000", "--access-logfile", "-", "api.wsgi:app"] 24 | ``` 25 | 26 | ## Build the Docker image, run and test 27 | 28 | You may use the original Discord Access container build processes from the primary README.md: 29 | ```bash 30 | docker compose up --build 31 | ``` 32 | 33 | Verify Slack notifications are work as designed. 34 | 35 | ## Plugin Configuration 36 | 37 | The plugin requires the following environment variables to be set: 38 | 39 | - `SLACK_BOT_TOKEN`: The token for your Slack bot. 40 | - `SLACK_ALERTS_CHANNEL`: The channel where alerts will be sent. String name like `#alerts-discord-access` 41 | - `CLIENT_ORIGIN_URL`: The base URL for your application. 42 | 43 | ## Plugin Structure 44 | 45 | The plugin consists of the following components: 46 | 47 | - **Notifications Slack**: This component handles sending notifications to Slack when access requests are created, completed, or expiring. 48 | 49 | ## Usage 50 | 51 | After installing and setting up the plugin, it automatically sends notifications to the relevant users and owners when an access request is created, completed, or is about to expire. You can also choose to send these notifications to a designated Slack alerts channel for logging and better visibility by setting SLACK_ALERTS_CHANNEL. 52 | 53 | ## Development 54 | 55 | To contribute to the development of this plugin, please follow the standard Git workflow: 56 | 57 | 1. Fork the repository. 58 | 2. Create a new branch for your feature or bug fix. 59 | 3. Make your changes and commit them. 60 | 4. Push your branch and create a pull request. 61 | 62 | ## License 63 | 64 | This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. 65 | -------------------------------------------------------------------------------- /src/components/MembershipChip.tsx: -------------------------------------------------------------------------------- 1 | import {OktaUserGroupMember, PolymorphicGroup, RoleGroup} from '../api/apiSchemas'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import {useCurrentUser} from '../authentication'; 4 | import {canManageGroup, isGroupOwner} from '../authorization'; 5 | import Chip from '@mui/material/Chip'; 6 | import Tooltip from '@mui/material/Tooltip'; 7 | 8 | export interface MembershipChipProps { 9 | okta_user_group_member: OktaUserGroupMember; 10 | group: PolymorphicGroup; 11 | removeRoleGroup: (roleGroup: RoleGroup) => void; 12 | removeDirectAccessAsUser: () => void; 13 | removeDirectAccessAsGroupManager: () => void; 14 | } 15 | 16 | export default function MembershipChip({ 17 | okta_user_group_member, 18 | group, 19 | removeRoleGroup, 20 | removeDirectAccessAsUser, 21 | removeDirectAccessAsGroupManager, 22 | }: MembershipChipProps) { 23 | const navigate = useNavigate(); 24 | const currentUser = useCurrentUser(); 25 | const activeRoleGroup = okta_user_group_member.active_role_group_mapping?.active_role_group; 26 | const ending_date = okta_user_group_member.ended_at ?? 'Never'; 27 | const canManageUserRoleGroup = activeRoleGroup?.id ? isGroupOwner(currentUser, activeRoleGroup.id) : false; 28 | const canManageThisGroup = group.is_managed && canManageGroup(currentUser, group); 29 | const canManageThisUser = group.is_managed && currentUser.id === okta_user_group_member.active_user?.id; 30 | 31 | const moveTooltip = {modifiers: [{name: 'offset', options: {offset: [0, -10]}}]}; 32 | 33 | return activeRoleGroup ? ( 34 | 35 | navigate(`/roles/${activeRoleGroup.name}`)} 40 | onDelete={ 41 | canManageThisGroup || canManageUserRoleGroup 42 | ? () => { 43 | removeRoleGroup(activeRoleGroup); 44 | } 45 | : undefined 46 | } 47 | /> 48 | 49 | ) : ( 50 | 51 | { 57 | removeDirectAccessAsUser(); 58 | } 59 | : canManageThisGroup 60 | ? () => { 61 | removeDirectAccessAsGroupManager(); 62 | } 63 | : undefined 64 | } 65 | /> 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /api/operations/delete_tag.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from flask import current_app, has_request_context, request 4 | 5 | from api.extensions import db 6 | from api.models import AppTagMap, OktaGroupTagMap, OktaUser, Tag 7 | from api.views.schemas import AuditLogSchema, EventType 8 | 9 | 10 | class DeleteTag: 11 | def __init__(self, *, tag: Tag | str, current_user_id: Optional[str] = None): 12 | self.tag = Tag.query.filter(Tag.id == (tag if isinstance(tag, str) else tag.id)).first() 13 | self.current_user_id = getattr( 14 | OktaUser.query.filter(OktaUser.deleted_at.is_(None)).filter(OktaUser.id == current_user_id).first(), 15 | "id", 16 | None, 17 | ) 18 | 19 | def execute(self) -> None: 20 | # Audit logging 21 | email = None 22 | if self.current_user_id is not None: 23 | email = getattr(db.session.get(OktaUser, self.current_user_id), "email", None) 24 | 25 | context = has_request_context() 26 | 27 | current_app.logger.info( 28 | AuditLogSchema().dumps( 29 | { 30 | "event_type": EventType.tag_delete, 31 | "user_agent": request.headers.get("User-Agent") if context else None, 32 | "ip": request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) 33 | if context 34 | else None, 35 | "current_user_id": self.current_user_id, 36 | "current_user_email": email, 37 | "tag": self.tag, 38 | } 39 | ) 40 | ) 41 | 42 | # Disable and delete tag 43 | self.tag.enabled = False 44 | self.tag.deleted_at = db.func.now() 45 | 46 | # End all active group tag mappings for tag 47 | OktaGroupTagMap.query.filter( 48 | db.or_( 49 | OktaGroupTagMap.ended_at.is_(None), 50 | OktaGroupTagMap.ended_at > db.func.now(), 51 | ) 52 | ).filter(OktaGroupTagMap.tag_id == self.tag.id).update( 53 | {OktaGroupTagMap.ended_at: db.func.now()}, synchronize_session="fetch" 54 | ) 55 | 56 | # End all active app tag mappings for tag 57 | AppTagMap.query.filter( 58 | db.or_( 59 | AppTagMap.ended_at.is_(None), 60 | AppTagMap.ended_at > db.func.now(), 61 | ) 62 | ).filter(AppTagMap.tag_id == self.tag.id).update( 63 | {AppTagMap.ended_at: db.func.now()}, synchronize_session="fetch" 64 | ) 65 | 66 | db.session.commit() 67 | -------------------------------------------------------------------------------- /src/api/apiFetcher.ts: -------------------------------------------------------------------------------- 1 | import {ApiContext} from './apiContext'; 2 | 3 | const baseUrl = import.meta.env.VITE_API_SERVER_URL; 4 | 5 | export type ErrorWrapper = TError | {status: 'unknown'; payload: string}; 6 | 7 | export type ErrorMessage = { 8 | message: string; 9 | }; 10 | 11 | export type ApiFetcherOptions = { 12 | url: string; 13 | method: string; 14 | body?: TBody; 15 | headers?: THeaders; 16 | queryParams?: TQueryParams; 17 | pathParams?: TPathParams; 18 | signal?: AbortSignal; 19 | } & ApiContext['fetcherOptions']; 20 | 21 | export async function apiFetch< 22 | TData, 23 | TError, 24 | TBody extends {} | undefined | null, 25 | THeaders extends {}, 26 | TQueryParams extends {}, 27 | TPathParams extends {}, 28 | >({ 29 | url, 30 | method, 31 | body, 32 | headers, 33 | pathParams, 34 | queryParams, 35 | signal, 36 | }: ApiFetcherOptions): Promise { 37 | try { 38 | const response = await window.fetch(`${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, { 39 | signal, 40 | method: method.toUpperCase(), 41 | body: body ? JSON.stringify(body) : undefined, 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | ...headers, 45 | }, 46 | }); 47 | if (!response.ok) { 48 | let error: ErrorWrapper; 49 | try { 50 | error = await response.json(); 51 | } catch (e) { 52 | error = { 53 | status: 'unknown' as const, 54 | payload: e instanceof Error ? `Unexpected error (${e.message})` : 'Unexpected error', 55 | }; 56 | } 57 | 58 | throw error; 59 | } 60 | 61 | if (response.headers.get('content-type')?.includes('json')) { 62 | return await response.json(); 63 | } else { 64 | // if it is not a json response, assume it is a blob and cast it to TData 65 | return (await response.blob()) as unknown as TData; 66 | } 67 | } catch (e) { 68 | throw { 69 | status: 'unknown' as const, 70 | payload: 71 | e instanceof Error 72 | ? `Network error (${e.message})` 73 | : (e as ErrorMessage).message != null 74 | ? (e as ErrorMessage).message 75 | : 'Network error', 76 | }; 77 | } 78 | } 79 | 80 | const resolveUrl = (url: string, queryParams: Record = {}, pathParams: Record = {}) => { 81 | let query = new URLSearchParams(queryParams).toString(); 82 | if (query) query = `?${query}`; 83 | return url.replace(/\{\w*\}/g, (key) => pathParams[key.slice(1, -1)]) + query; 84 | }; 85 | -------------------------------------------------------------------------------- /api/operations/create_tag.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from typing import Any, Optional, TypedDict 4 | 5 | from flask import current_app, has_request_context, request 6 | from sqlalchemy import func 7 | 8 | from api.extensions import db 9 | from api.models import OktaUser, Tag 10 | from api.views.schemas import AuditLogSchema, EventType 11 | 12 | 13 | class TagDict(TypedDict): 14 | name: str 15 | description: str 16 | constraints: dict[str, Any] 17 | 18 | 19 | class CreateTag: 20 | def __init__(self, *, tag: Tag | TagDict, current_user_id: Optional[str] = None): 21 | id = self.__generate_id() 22 | if isinstance(tag, dict): 23 | self.tag = Tag(id=id, name=tag["name"], description=tag["description"], constraints=tag["constraints"]) 24 | else: 25 | tag.id = id 26 | self.tag = tag 27 | 28 | self.current_user_id = getattr( 29 | OktaUser.query.filter(OktaUser.deleted_at.is_(None)).filter(OktaUser.id == current_user_id).first(), 30 | "id", 31 | None, 32 | ) 33 | 34 | def execute(self) -> Tag: 35 | # Do not allow non-deleted groups with the same name (case-insensitive) 36 | existing_tag = ( 37 | Tag.query.filter(func.lower(Tag.name) == func.lower(self.tag.name)).filter(Tag.deleted_at.is_(None)).first() 38 | ) 39 | if existing_tag is not None: 40 | return existing_tag 41 | 42 | db.session.add(self.tag) 43 | db.session.commit() 44 | 45 | # Audit logging 46 | email = None 47 | if self.current_user_id is not None: 48 | email = getattr(db.session.get(OktaUser, self.current_user_id), "email", None) 49 | 50 | context = has_request_context() 51 | 52 | current_app.logger.info( 53 | AuditLogSchema().dumps( 54 | { 55 | "event_type": EventType.tag_create, 56 | "user_agent": request.headers.get("User-Agent") if context else None, 57 | "ip": request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) 58 | if context 59 | else None, 60 | "current_user_id": self.current_user_id, 61 | "current_user_email": email, 62 | "tag": self.tag, 63 | } 64 | ) 65 | ) 66 | 67 | return self.tag 68 | 69 | # Generate a 20 character alphanumeric ID similar to Okta IDs for users and groups 70 | def __generate_id(self) -> str: 71 | return "".join(random.choices(string.ascii_letters, k=20)) 72 | -------------------------------------------------------------------------------- /api/extensions.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional, ParamSpec, Tuple 2 | 3 | from flask import jsonify 4 | from flask_marshmallow import Marshmallow 5 | from flask_migrate import Migrate 6 | from flask_oidc import OpenIDConnect 7 | from flask_restful import Api as _Api 8 | from flask_sqlalchemy import SQLAlchemy 9 | from google.cloud.sql.connector import Connector, IPTypes 10 | from marshmallow import exceptions 11 | from sqlalchemy import MetaData 12 | from sqlalchemy.orm import DeclarativeBase 13 | from werkzeug.wrappers import Response 14 | 15 | from api.apispec import FlaskApiSpecExt 16 | 17 | 18 | class Base(DeclarativeBase): 19 | metadata = MetaData( 20 | naming_convention={ 21 | "ix": "ix_%(column_0_label)s", 22 | "uq": "uq_%(table_name)s_%(column_0_name)s", 23 | "ck": "ck_%(table_name)s_%(constraint_name)s", 24 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 25 | "pk": "pk_%(table_name)s", 26 | } 27 | ) 28 | 29 | 30 | db = SQLAlchemy(model_class=Base) 31 | ma = Marshmallow() 32 | migrate = Migrate(render_as_batch=True) 33 | docs = FlaskApiSpecExt() 34 | oidc = OpenIDConnect() 35 | 36 | P = ParamSpec("P") 37 | 38 | 39 | class Api(_Api): 40 | def error_router( 41 | self, original_handler: Callable[P, Tuple[Response, int]], e: BaseException 42 | ) -> Tuple[Response, int]: 43 | if isinstance(e, exceptions.ValidationError): 44 | if isinstance(e.messages, list): 45 | return jsonify(message=e.messages[0]), 400 46 | elif isinstance(e.messages, dict): 47 | return jsonify( 48 | message=list(e.normalized_messages().values())[0][0] # type: ignore[no-untyped-call] 49 | ), 400 50 | else: 51 | return jsonify(message=e.messages), 400 52 | 53 | return super().error_router(original_handler, e) 54 | 55 | 56 | def get_cloudsql_conn( 57 | cloudsql_connection_name: str, 58 | db_user: Optional[str] = "root", 59 | db_name: Optional[str] = "access", 60 | uses_public_ip: Optional[bool] = False, 61 | ) -> Callable[[], Connector]: 62 | def _get_conn() -> Connector: 63 | with Connector() as connector: 64 | conn = connector.connect( 65 | cloudsql_connection_name, # Cloud SQL Instance Connection Name 66 | "pg8000", 67 | user=db_user, 68 | db=db_name, 69 | ip_type=IPTypes.PUBLIC if uses_public_ip else IPTypes.PRIVATE, 70 | enable_iam_auth=True, 71 | ) 72 | return conn 73 | 74 | return _get_conn 75 | -------------------------------------------------------------------------------- /tests/test_expire_access_request.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | from flask_sqlalchemy import SQLAlchemy 4 | 5 | from api.models import AccessRequest, AccessRequestStatus, OktaGroup, OktaUser 6 | from api.syncer import expire_access_requests 7 | 8 | 9 | def test_no_expire_new_access_request( 10 | db: SQLAlchemy, access_request: AccessRequest, okta_group: OktaGroup, user: OktaUser 11 | ) -> None: 12 | db.session.add(user) 13 | db.session.add(okta_group) 14 | db.session.commit() 15 | access_request.requested_group_id = okta_group.id 16 | access_request.requester_user_id = user.id 17 | db.session.add(access_request) 18 | db.session.commit() 19 | 20 | access_request_id = access_request.id 21 | 22 | expire_access_requests() 23 | 24 | access_request = db.session.get(AccessRequest, access_request_id) 25 | assert access_request.status == AccessRequestStatus.PENDING 26 | assert access_request.resolved_at is None 27 | 28 | 29 | def test_expire_old_access_request( 30 | db: SQLAlchemy, access_request: AccessRequest, okta_group: OktaGroup, user: OktaUser 31 | ) -> None: 32 | db.session.add(user) 33 | db.session.add(okta_group) 34 | db.session.commit() 35 | access_request.created_at = datetime.now(timezone.utc) - timedelta(days=30) 36 | access_request.requested_group_id = okta_group.id 37 | access_request.requester_user_id = user.id 38 | db.session.add(access_request) 39 | db.session.commit() 40 | 41 | access_request_id = access_request.id 42 | 43 | expire_access_requests() 44 | 45 | access_request = db.session.get(AccessRequest, access_request_id) 46 | assert access_request.status == AccessRequestStatus.REJECTED 47 | assert access_request.resolved_at is not None 48 | 49 | 50 | def test_expire_old_temporary_access_request( 51 | db: SQLAlchemy, access_request: AccessRequest, okta_group: OktaGroup, user: OktaUser 52 | ) -> None: 53 | db.session.add(user) 54 | db.session.add(okta_group) 55 | db.session.commit() 56 | access_request.created_at = datetime.now(timezone.utc) - timedelta(days=1) 57 | access_request.request_ending_at = datetime.now(timezone.utc) - timedelta(hours=12) 58 | access_request.requested_group_id = okta_group.id 59 | access_request.requester_user_id = user.id 60 | db.session.add(access_request) 61 | db.session.commit() 62 | 63 | access_request_id = access_request.id 64 | 65 | expire_access_requests() 66 | 67 | access_request = db.session.get(AccessRequest, access_request_id) 68 | assert access_request.status == AccessRequestStatus.REJECTED 69 | assert access_request.resolved_at is not None 70 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import pytest 4 | from dotenv import load_dotenv 5 | from flask import Flask 6 | from flask_sqlalchemy import SQLAlchemy 7 | from pytest_factoryboy import register 8 | 9 | from api.app import create_app 10 | from api.extensions import db as _db 11 | from api.models import App, AppGroup, OktaUserGroupMember 12 | from tests.factories import ( 13 | AccessRequestFactory, 14 | AppFactory, 15 | AppGroupFactory, 16 | OktaGroupFactory, 17 | OktaUserFactory, 18 | RoleGroupFactory, 19 | RoleRequestFactory, 20 | TagFactory, 21 | ) 22 | 23 | register(OktaUserFactory, "user") 24 | register(OktaGroupFactory, "okta_group") 25 | register(RoleGroupFactory, "role_group") 26 | register(AppGroupFactory, "app_group") 27 | register(AppFactory, "access_app") 28 | register(AccessRequestFactory, "access_request") 29 | register(RoleRequestFactory, "role_request") 30 | register(TagFactory, "tag") 31 | 32 | 33 | @pytest.fixture(scope="session") 34 | def app(request: pytest.FixtureRequest) -> Flask: 35 | load_dotenv(".testenv") 36 | app = create_app(testing=True) 37 | 38 | # Check if parametrization is being used (indirect parametrization) 39 | require_descriptions = getattr(request, "param", False) 40 | # Set the config on the Flask app (schemas read from current_app.config at validation time) 41 | app.config["REQUIRE_DESCRIPTIONS"] = require_descriptions 42 | 43 | return app 44 | 45 | 46 | @pytest.fixture 47 | def db(app: Flask) -> Generator[SQLAlchemy, None, None]: 48 | # Drop the data at the beginning of the test to guarantee 49 | # a clean database, and to allow DB inspection after a test run. 50 | _db.drop_all() 51 | _db.app = app 52 | 53 | with app.app_context(): 54 | _db.create_all() 55 | 56 | access_owner = OktaUserFactory.build(email=app.config["CURRENT_OKTA_USER_EMAIL"]) 57 | access_app = AppFactory.build( 58 | name=App.ACCESS_APP_RESERVED_NAME, description=f"The {App.ACCESS_APP_RESERVED_NAME} Portal" 59 | ) 60 | access_app_owner_group = AppGroupFactory.build( 61 | app_id=access_app.id, 62 | is_owner=True, 63 | name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{access_app.name}" 64 | + f"{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}{AppGroup.APP_OWNERS_GROUP_NAME_SUFFIX}", 65 | description=f"Owners of the {access_app.name} application", 66 | ) 67 | access_app_owner_group_membership = OktaUserGroupMember(user_id=access_owner.id, group_id=access_app_owner_group.id) 68 | _db.session.add(access_owner) 69 | _db.session.add(access_app) 70 | _db.session.add(access_app_owner_group) 71 | _db.session.add(access_app_owner_group_membership) 72 | _db.session.commit() 73 | 74 | yield _db 75 | 76 | _db.session.close() 77 | -------------------------------------------------------------------------------- /src/api/apiContext.ts: -------------------------------------------------------------------------------- 1 | import type {QueryKey, UseQueryOptions} from '@tanstack/react-query'; 2 | import {QueryOperation} from './apiComponents'; 3 | 4 | export type ApiContext = { 5 | fetcherOptions: { 6 | /** 7 | * Headers to inject in the fetcher 8 | */ 9 | headers?: {}; 10 | /** 11 | * Query params to inject in the fetcher 12 | */ 13 | queryParams?: {}; 14 | }; 15 | queryOptions: { 16 | /** 17 | * Set this to `false` to disable automatic refetching when the query mounts or changes query keys. 18 | * Defaults to `true`. 19 | */ 20 | enabled?: boolean; 21 | }; 22 | /** 23 | * Query key manager. 24 | */ 25 | queryKeyFn: (operation: QueryOperation) => QueryKey; 26 | }; 27 | 28 | /** 29 | * Context injected into every react-query hook wrappers 30 | * 31 | * @param queryOptions options from the useQuery wrapper 32 | */ 33 | export function useApiContext< 34 | TQueryFnData = unknown, 35 | TError = unknown, 36 | TData = TQueryFnData, 37 | TQueryKey extends QueryKey = QueryKey, 38 | >(_queryOptions?: Omit, 'queryKey' | 'queryFn'>): ApiContext { 39 | return { 40 | fetcherOptions: {}, 41 | queryOptions: {}, 42 | queryKeyFn: (operation) => { 43 | const queryKey: unknown[] = hasPathParams(operation) 44 | ? operation.path 45 | .split('/') 46 | .filter(Boolean) 47 | .map((i) => resolvePathParam(i, operation.variables.pathParams)) 48 | : operation.path.split('/').filter(Boolean); 49 | 50 | if (hasQueryParams(operation)) { 51 | queryKey.push(operation.variables.queryParams); 52 | } 53 | 54 | if (hasBody(operation)) { 55 | queryKey.push(operation.variables.body); 56 | } 57 | 58 | return queryKey; 59 | }, 60 | }; 61 | } 62 | 63 | // Helpers 64 | const resolvePathParam = (key: string, pathParams: Record) => { 65 | if (key.startsWith('{') && key.endsWith('}')) { 66 | return pathParams[key.slice(1, -1)]; 67 | } 68 | return key; 69 | }; 70 | 71 | const hasPathParams = ( 72 | operation: QueryOperation, 73 | ): operation is QueryOperation & { 74 | variables: {pathParams: Record}; 75 | } => { 76 | return Boolean((operation.variables as any).pathParams); 77 | }; 78 | 79 | const hasBody = ( 80 | operation: QueryOperation, 81 | ): operation is QueryOperation & { 82 | variables: {body: Record}; 83 | } => { 84 | return Boolean((operation.variables as any).body); 85 | }; 86 | 87 | const hasQueryParams = ( 88 | operation: QueryOperation, 89 | ): operation is QueryOperation & { 90 | variables: {queryParams: Record}; 91 | } => { 92 | return Boolean((operation.variables as any).queryParams); 93 | }; 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "access", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@mui/base": "^5.0.0-beta.28", 8 | "@mui/icons-material": "latest", 9 | "@mui/lab": "latest", 10 | "@mui/material": "latest", 11 | "@mui/styled-engine-sc": "latest", 12 | "@mui/x-data-grid": "^6.18.4", 13 | "@mui/x-date-pickers": "latest", 14 | "@sentry/react": "latest", 15 | "@tanstack/react-query": "latest", 16 | "dayjs": "latest", 17 | "env-cmd": "latest", 18 | "react": "^18.0.0", 19 | "react-dom": "^18.0.0", 20 | "react-hook-form": "latest", 21 | "react-hook-form-mui": "latest", 22 | "react-markdown": "^10.1.0", 23 | "react-router-dom": "latest", 24 | "styled-components": "latest" 25 | }, 26 | "scripts": { 27 | "tsc": "./node_modules/.bin/tsc", 28 | "start": "vite", 29 | "build": "env-cmd -f .env.production vite build", 30 | "preview": "vite preview", 31 | "test": "vitest", 32 | "prepare": "husky install" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "@sentry/vite-plugin": "^4.0.2", 48 | "@testing-library/jest-dom": "^6.6.4", 49 | "@testing-library/react": "latest", 50 | "@testing-library/user-event": "latest", 51 | "@types/node": "^24.2.0", 52 | "@types/react": "latest", 53 | "@types/react-dom": "latest", 54 | "@types/styled-components": "latest", 55 | "@vitejs/plugin-react": "^4.7.0", 56 | "@vitest/ui": "^3.2.4", 57 | "husky": "^8.0.3", 58 | "jsdom": "^26.1.0", 59 | "lint-staged": "^15.2.10", 60 | "prettier": "^3.2.5", 61 | "typescript": "latest", 62 | "vite": "^7.1.11", 63 | "vitest": "^3.2.4" 64 | }, 65 | "optionalDependencies": { 66 | "@rollup/rollup-darwin-arm64": "4.46.2", 67 | "@rollup/rollup-darwin-x64": "4.46.2", 68 | "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", 69 | "@rollup/rollup-linux-arm-musleabihf": "4.46.2", 70 | "@rollup/rollup-linux-arm64-gnu": "4.46.2", 71 | "@rollup/rollup-linux-arm64-musl": "4.46.2", 72 | "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", 73 | "@rollup/rollup-linux-ppc64-gnu": "4.46.2", 74 | "@rollup/rollup-linux-riscv64-gnu": "4.46.2", 75 | "@rollup/rollup-linux-riscv64-musl": "4.46.2", 76 | "@rollup/rollup-linux-s390x-gnu": "4.46.2", 77 | "@rollup/rollup-linux-x64-gnu": "4.46.2", 78 | "@rollup/rollup-linux-x64-musl": "4.46.2", 79 | "@rollup/rollup-win32-arm64-msvc": "4.46.2", 80 | "@rollup/rollup-win32-ia32-msvc": "4.46.2", 81 | "@rollup/rollup-win32-x64-msvc": "4.46.2" 82 | }, 83 | "lint-staged": { 84 | "*.{js,ts,jsx,tsx}": "prettier --write" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /api/operations/delete_app.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from flask import current_app, has_request_context, request 4 | 5 | from api.extensions import db 6 | from api.models import App, AppGroup, AppTagMap, OktaUser 7 | from api.operations.delete_group import DeleteGroup 8 | from api.views.schemas import AuditLogSchema, EventType 9 | 10 | 11 | class DeleteApp: 12 | def __init__(self, *, app: App | str, current_user_id: Optional[str] = None): 13 | if isinstance(app, str): 14 | self.app = App.query.filter(App.deleted_at.is_(None)).filter(App.id == app).first() 15 | else: 16 | self.app = app 17 | 18 | self.current_user_id = getattr( 19 | OktaUser.query.filter(OktaUser.deleted_at.is_(None)).filter(OktaUser.id == current_user_id).first(), 20 | "id", 21 | None, 22 | ) 23 | 24 | def execute(self) -> None: 25 | # Prevent access app deletion 26 | if self.app.name == App.ACCESS_APP_RESERVED_NAME: 27 | raise ValueError("The Access Application cannot be deleted") 28 | 29 | # Audit logging 30 | email = None 31 | if self.current_user_id is not None: 32 | email = getattr(db.session.get(OktaUser, self.current_user_id), "email", None) 33 | 34 | context = has_request_context() 35 | 36 | current_app.logger.info( 37 | AuditLogSchema().dumps( 38 | { 39 | "event_type": EventType.app_delete, 40 | "user_agent": request.headers.get("User-Agent") if context else None, 41 | "ip": request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) 42 | if context 43 | else None, 44 | "current_user_id": self.current_user_id, 45 | "current_user_email": email, 46 | "app": self.app, 47 | } 48 | ) 49 | ) 50 | 51 | self.app.deleted_at = db.func.now() 52 | db.session.commit() 53 | 54 | # Delete all associated Okta App Groups and end their membership 55 | app_groups = AppGroup.query.filter(AppGroup.deleted_at.is_(None)).filter(AppGroup.app_id == self.app.id) 56 | app_group_ids = [ag.id for ag in app_groups] 57 | for app_group_id in app_group_ids: 58 | DeleteGroup(group=app_group_id, current_user_id=self.current_user_id).execute() 59 | 60 | # End all tag mappings for this app (OktaGroupTagMaps are ended by the DeleteGroup operation above) 61 | AppTagMap.query.filter(AppTagMap.app_id == self.app.id).filter( 62 | db.or_( 63 | AppTagMap.ended_at.is_(None), 64 | AppTagMap.ended_at > db.func.now(), 65 | ) 66 | ).update( 67 | {AppTagMap.ended_at: db.func.now()}, 68 | synchronize_session="fetch", 69 | ) 70 | db.session.commit() 71 | -------------------------------------------------------------------------------- /src/pages/apps/components/AppsHeader.tsx: -------------------------------------------------------------------------------- 1 | import {Grid, Paper, Typography, Box, Chip, Stack, Tooltip, Divider} from '@mui/material'; 2 | import CreateUpdateApp from '../CreateUpdate'; 3 | import DeleteApp from '../Delete'; 4 | import {App, OktaUser} from '../../../api/apiSchemas'; 5 | import {useNavigate} from 'react-router-dom'; 6 | import React from 'react'; 7 | 8 | import TagIcon from '@mui/icons-material/LocalOffer'; 9 | import {isAccessAdmin, isAppOwnerGroupOwner} from '../../../authorization'; 10 | import MarkdownDescription from '../../../components/MarkdownDescription'; 11 | 12 | interface AppsHeaderProps { 13 | app: App; 14 | currentUser: OktaUser; 15 | } 16 | 17 | export const AppsHeader: React.FC = React.memo(({app, currentUser}) => { 18 | const navigate = useNavigate(); 19 | const moveTooltip = {modifiers: [{name: 'offset', options: {offset: [0, -10]}}]}; 20 | 21 | const hasActions = React.useMemo(() => { 22 | return isAccessAdmin(currentUser) || isAppOwnerGroupOwner(currentUser, app.id ?? ''); 23 | }, [currentUser, app.id]); 24 | 25 | const tagChips = React.useMemo(() => { 26 | if (!app.active_app_tags) return null; 27 | 28 | return app.active_app_tags.map((tagMap) => ( 29 | navigate(`/tags/${tagMap.active_tag!.name}`)} 34 | icon={} 35 | sx={{ 36 | margin: '.125rem', 37 | marginTop: '.3125rem', 38 | bgcolor: (theme) => (tagMap.active_tag!.enabled ? 'primary' : theme.palette.action.disabled), 39 | }} 40 | /> 41 | )); 42 | }, [app.active_app_tags, navigate]); 43 | 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | {app.name} 51 | 52 | 53 | {tagChips && {tagChips}} 54 | 55 | {hasActions && ( 56 | <> 57 | 58 | 59 | 60 |
    61 | 62 |
    63 |
    64 | 65 |
    66 | 67 |
    68 |
    69 |
    70 | 71 | )} 72 |
    73 |
    74 |
    75 | ); 76 | }); 77 | 78 | export default AppsHeader; 79 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from flask import current_app 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | if config.config_file_name is not None: 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger("alembic.env") 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | config.set_main_option("sqlalchemy.url", str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%")) 25 | target_metadata = current_app.extensions["migrate"].db.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline(): 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure(url=url, target_metadata=target_metadata, literal_binds=True) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def run_migrations_online(): 53 | """Run migrations in 'online' mode. 54 | 55 | In this scenario we need to create an Engine 56 | and associate a connection with the context. 57 | 58 | """ 59 | 60 | # this callback is used to prevent an auto-migration from being generated 61 | # when there are no changes to the schema 62 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 63 | def process_revision_directives(context, revision, directives): 64 | if getattr(config.cmd_opts, "autogenerate", False): 65 | script = directives[0] 66 | if script.upgrade_ops.is_empty(): 67 | directives[:] = [] 68 | logger.info("No changes in schema detected.") 69 | 70 | connectable = current_app.extensions["migrate"].db.get_engine() 71 | 72 | with connectable.connect() as connection: 73 | context.configure( 74 | connection=connection, 75 | target_metadata=target_metadata, 76 | process_revision_directives=process_revision_directives, 77 | **current_app.extensions["migrate"].configure_args, 78 | ) 79 | 80 | with context.begin_transaction(): 81 | context.run_migrations() 82 | 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /src/pages/tags/Delete.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import Button from '@mui/material/Button'; 4 | import IconButton from '@mui/material/IconButton'; 5 | import Dialog from '@mui/material/Dialog'; 6 | import DialogActions from '@mui/material/DialogActions'; 7 | import DialogContent from '@mui/material/DialogContent'; 8 | import DialogContentText from '@mui/material/DialogContentText'; 9 | import DialogTitle from '@mui/material/DialogTitle'; 10 | import DeleteIcon from '@mui/icons-material/DeleteForever'; 11 | import Alert from '@mui/material/Alert'; 12 | import CircularProgress from '@mui/material/CircularProgress'; 13 | 14 | import {useDeleteTagById, DeleteTagByIdError, DeleteTagByIdVariables} from '../../api/apiComponents'; 15 | import {Tag, OktaUser} from '../../api/apiSchemas'; 16 | import {isAccessAdmin} from '../../authorization'; 17 | 18 | interface TagDialogProps { 19 | setOpen(open: boolean): any; 20 | tag: Tag; 21 | } 22 | 23 | function TagDialog(props: TagDialogProps) { 24 | const navigate = useNavigate(); 25 | 26 | const [requestError, setRequestError] = React.useState(''); 27 | const [submitting, setSubmitting] = React.useState(false); 28 | 29 | const complete = ( 30 | deletedTag: Tag | undefined, 31 | error: DeleteTagByIdError | null, 32 | variables: DeleteTagByIdVariables, 33 | context: any, 34 | ) => { 35 | setSubmitting(false); 36 | if (error != null) { 37 | setRequestError(error.payload.toString()); 38 | } else { 39 | props.setOpen(false); 40 | navigate('/tags/'); 41 | } 42 | }; 43 | 44 | const deleteTag = useDeleteTagById({ 45 | onSettled: complete, 46 | }); 47 | 48 | const submit = () => { 49 | setSubmitting(true); 50 | deleteTag.mutate({ 51 | pathParams: {tagId: props.tag?.id ?? ''}, 52 | }); 53 | }; 54 | 55 | return ( 56 | props.setOpen(false)}> 57 | Delete Tag 58 | 59 | {requestError != '' ? {requestError} : null} 60 | 61 | Are you sure you want to delete Tag "{props.tag.name}"? 62 | 63 | 64 | 65 | 66 | 69 | 70 | 71 | ); 72 | } 73 | 74 | interface DeleteTagProps { 75 | currentUser: OktaUser; 76 | tag: Tag; 77 | } 78 | 79 | export default function DeleteTag(props: DeleteTagProps) { 80 | const [open, setOpen] = React.useState(false); 81 | 82 | if (props.tag.deleted_at != null || !isAccessAdmin(props.currentUser)) { 83 | return null; 84 | } 85 | 86 | return ( 87 | <> 88 | setOpen(true)}> 89 | 90 | 91 | {open ? : null} 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/pages/groups/RemoveOwnDirectAccess.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import Button from '@mui/material/Button'; 4 | import Dialog from '@mui/material/Dialog'; 5 | import DialogActions from '@mui/material/DialogActions'; 6 | import DialogContent from '@mui/material/DialogContent'; 7 | import DialogContentText from '@mui/material/DialogContentText'; 8 | import DialogTitle from '@mui/material/DialogTitle'; 9 | import Alert from '@mui/material/Alert'; 10 | import CircularProgress from '@mui/material/CircularProgress'; 11 | 12 | import {PutGroupMembersByIdError, PutGroupMembersByIdVariables, usePutGroupMembersById} from '../../api/apiComponents'; 13 | import {GroupMember, PolymorphicGroup} from '../../api/apiSchemas'; 14 | 15 | export interface RemoveOwnDirectAccessDialogParameters { 16 | userId: string; 17 | group: PolymorphicGroup; 18 | owner: boolean; 19 | } 20 | 21 | interface RemoveOwnDirectAccesssDialogProps extends RemoveOwnDirectAccessDialogParameters { 22 | setOpen(open: boolean): any; 23 | } 24 | 25 | export default function RemoveOwnDirectAccessDialog(props: RemoveOwnDirectAccesssDialogProps) { 26 | const navigate = useNavigate(); 27 | 28 | const [requestError, setRequestError] = React.useState(''); 29 | const [submitting, setSubmitting] = React.useState(false); 30 | 31 | const complete = ( 32 | completedUsersChange: GroupMember | undefined, 33 | error: PutGroupMembersByIdError | null, 34 | variables: PutGroupMembersByIdVariables, 35 | context: any, 36 | ) => { 37 | setSubmitting(false); 38 | if (error != null) { 39 | setRequestError(error.payload.toString()); 40 | } else { 41 | props.setOpen(false); 42 | navigate(0); 43 | } 44 | }; 45 | 46 | const putGroupUsers = usePutGroupMembersById({ 47 | onSettled: complete, 48 | }); 49 | 50 | const submit = () => { 51 | setSubmitting(true); 52 | 53 | const groupUsers: GroupMember = { 54 | members_to_add: [], 55 | members_to_remove: [], 56 | owners_to_add: [], 57 | owners_to_remove: [], 58 | }; 59 | 60 | if (props.owner) { 61 | groupUsers.owners_to_remove = [props.userId]; 62 | } else { 63 | groupUsers.members_to_remove = [props.userId]; 64 | } 65 | 66 | putGroupUsers.mutate({ 67 | body: groupUsers, 68 | pathParams: {groupId: props.group.id ?? ''}, 69 | }); 70 | }; 71 | 72 | return ( 73 | props.setOpen(false)}> 74 | Remove Own Direct Access 75 | 76 | {/* {requestError != '' ? {requestError} : null} */} 77 | 78 | Are you sure you want to remove yourself as {props.owner ? 'an owner' : 'a member'} of{' '} 79 | {props.group.name} ? 80 | 81 | 82 | 83 | 84 | 87 | 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/pages/apps/Delete.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import Button from '@mui/material/Button'; 4 | import IconButton from '@mui/material/IconButton'; 5 | import Dialog from '@mui/material/Dialog'; 6 | import DialogActions from '@mui/material/DialogActions'; 7 | import DialogContent from '@mui/material/DialogContent'; 8 | import DialogContentText from '@mui/material/DialogContentText'; 9 | import DialogTitle from '@mui/material/DialogTitle'; 10 | import DeleteIcon from '@mui/icons-material/DeleteForever'; 11 | import Alert from '@mui/material/Alert'; 12 | import CircularProgress from '@mui/material/CircularProgress'; 13 | 14 | import {useDeleteAppById, DeleteAppByIdError, DeleteAppByIdVariables} from '../../api/apiComponents'; 15 | import {App, OktaUser} from '../../api/apiSchemas'; 16 | import {isAccessAdmin, isAppOwnerGroupOwner, ACCESS_APP_RESERVED_NAME} from '../../authorization'; 17 | 18 | interface AppDialogProps { 19 | setOpen(open: boolean): any; 20 | app: App; 21 | } 22 | 23 | function AppDialog(props: AppDialogProps) { 24 | const navigate = useNavigate(); 25 | 26 | const [requestError, setRequestError] = React.useState(''); 27 | const [submitting, setSubmitting] = React.useState(false); 28 | 29 | const complete = ( 30 | deletedApp: App | undefined, 31 | error: DeleteAppByIdError | null, 32 | variables: DeleteAppByIdVariables, 33 | context: any, 34 | ) => { 35 | setSubmitting(false); 36 | if (error != null) { 37 | setRequestError(error.payload.toString()); 38 | } else { 39 | props.setOpen(false); 40 | navigate('/apps/'); 41 | } 42 | }; 43 | 44 | const deleteApp = useDeleteAppById({ 45 | onSettled: complete, 46 | }); 47 | 48 | const submit = () => { 49 | setSubmitting(true); 50 | deleteApp.mutate({ 51 | pathParams: {appId: props.app?.id ?? ''}, 52 | }); 53 | }; 54 | 55 | return ( 56 | props.setOpen(false)}> 57 | Delete App 58 | 59 | {requestError != '' ? {requestError} : null} 60 | 61 | Are you sure you want to delete App "{props.app.name}" and all related app groups? 62 | 63 | 64 | 65 | 66 | 69 | 70 | 71 | ); 72 | } 73 | 74 | interface DeleteAppProps { 75 | currentUser: OktaUser; 76 | app: App; 77 | } 78 | 79 | export default function DeleteApp(props: DeleteAppProps) { 80 | const [open, setOpen] = React.useState(false); 81 | 82 | if ( 83 | !(isAccessAdmin(props.currentUser) || isAppOwnerGroupOwner(props.currentUser, props.app?.id ?? '')) || 84 | props.app?.name == ACCESS_APP_RESERVED_NAME 85 | ) { 86 | return null; 87 | } 88 | 89 | return ( 90 | <> 91 | setOpen(true)}> 92 | 93 | 94 | {open ? : null} 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /api/plugins/conditional_access.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | from typing import Any, Generator, List, Optional 6 | 7 | import pluggy 8 | 9 | from api.models import AccessRequest, OktaGroup, OktaUser, RoleGroup, RoleRequest, Tag 10 | 11 | conditional_access_plugin_name = "access_conditional_access" 12 | hookspec = pluggy.HookspecMarker(conditional_access_plugin_name) 13 | hookimpl = pluggy.HookimplMarker(conditional_access_plugin_name) 14 | 15 | _cached_conditional_access_hook: pluggy.HookRelay | None = None 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | @dataclass 21 | class ConditionalAccessResponse: 22 | approved: bool 23 | reason: str = "" 24 | ending_at: Optional[datetime] = None 25 | 26 | 27 | class ConditionalAccessPluginSpec: 28 | @hookspec 29 | def access_request_created( 30 | self, access_request: AccessRequest, group: OktaGroup, group_tags: List[Tag], requester: OktaUser 31 | ) -> Optional[ConditionalAccessResponse]: 32 | """Automatically approve, deny, or continue the access request.""" 33 | 34 | @hookspec 35 | def role_request_created( 36 | self, role_request: RoleRequest, role: RoleGroup, group: OktaGroup, group_tags: List[Tag], requester: OktaUser 37 | ) -> Optional[ConditionalAccessResponse]: 38 | """Automatically approve, deny, or continue the access request.""" 39 | 40 | 41 | @hookimpl(wrapper=True) 42 | def access_request_created( 43 | access_request: AccessRequest, group: OktaGroup, group_tags: List[Tag], requester: OktaUser 44 | ) -> Generator[Any, None, Optional[ConditionalAccessResponse]] | List[Optional[ConditionalAccessResponse]]: 45 | try: 46 | # Trigger exception if it exists 47 | return (yield) 48 | except Exception: 49 | # Log and do not raise since request failures should not 50 | # break the flow. The access request can still be manually 51 | # approved or denied 52 | logger.exception("Failed to execute access request created callback") 53 | 54 | return [] 55 | 56 | 57 | @hookimpl(wrapper=True) 58 | def role_request_created( 59 | role_request: RoleRequest, role: RoleGroup, group: OktaGroup, group_tags: List[Tag], requester: OktaUser 60 | ) -> Generator[Any, None, Optional[ConditionalAccessResponse]] | List[Optional[ConditionalAccessResponse]]: 61 | try: 62 | # Trigger exception if it exists 63 | return (yield) 64 | except Exception: 65 | # Log and do not raise since request failures should not 66 | # break the flow. The access request can still be manually 67 | # approved or denied 68 | logger.exception("Failed to execute role request created callback") 69 | 70 | return [] 71 | 72 | 73 | def get_conditional_access_hook() -> pluggy.HookRelay: 74 | global _cached_conditional_access_hook 75 | 76 | if _cached_conditional_access_hook is not None: 77 | return _cached_conditional_access_hook 78 | 79 | pm = pluggy.PluginManager(conditional_access_plugin_name) 80 | pm.add_hookspecs(ConditionalAccessPluginSpec) 81 | 82 | # Register the hook wrappers 83 | pm.register(sys.modules[__name__]) 84 | 85 | count = pm.load_setuptools_entrypoints(conditional_access_plugin_name) 86 | print(f"Count of loaded conditional access plugins: {count}") 87 | _cached_conditional_access_hook = pm.hook 88 | 89 | return _cached_conditional_access_hook 90 | -------------------------------------------------------------------------------- /src/pages/roles/RemoveGroups.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import Button from '@mui/material/Button'; 4 | import Dialog from '@mui/material/Dialog'; 5 | import DialogActions from '@mui/material/DialogActions'; 6 | import DialogContent from '@mui/material/DialogContent'; 7 | import DialogContentText from '@mui/material/DialogContentText'; 8 | import DialogTitle from '@mui/material/DialogTitle'; 9 | import Alert from '@mui/material/Alert'; 10 | import CircularProgress from '@mui/material/CircularProgress'; 11 | 12 | import {usePutRoleMembersById, PutRoleMembersByIdError, PutRoleMembersByIdVariables} from '../../api/apiComponents'; 13 | import {PolymorphicGroup, RoleGroup, RoleMember} from '../../api/apiSchemas'; 14 | 15 | export interface RemoveGroupsDialogParameters { 16 | group: PolymorphicGroup; 17 | role: RoleGroup; 18 | owner: boolean; 19 | } 20 | 21 | interface RemoveGroupsDialogProps extends RemoveGroupsDialogParameters { 22 | setOpen(open: boolean): any; 23 | } 24 | 25 | const GROUP_TYPE_ID_TO_LABELS: Record = { 26 | okta_group: 'Group', 27 | app_group: 'App Group', 28 | role_group: 'Role', 29 | } as const; 30 | 31 | export default function RemoveGroupsDialog(props: RemoveGroupsDialogProps) { 32 | const navigate = useNavigate(); 33 | 34 | const [requestError, setRequestError] = React.useState(''); 35 | const [submitting, setSubmitting] = React.useState(false); 36 | 37 | const complete = ( 38 | completedUsersChange: RoleMember | undefined, 39 | error: PutRoleMembersByIdError | null, 40 | variables: PutRoleMembersByIdVariables, 41 | context: any, 42 | ) => { 43 | setSubmitting(false); 44 | if (error != null) { 45 | setRequestError(error.payload.toString()); 46 | } else { 47 | props.setOpen(false); 48 | navigate(0); 49 | } 50 | }; 51 | 52 | const putGroupUsers = usePutRoleMembersById({ 53 | onSettled: complete, 54 | }); 55 | 56 | const submit = () => { 57 | setSubmitting(true); 58 | 59 | const roleMembers: RoleMember = { 60 | groups_to_add: [], 61 | groups_to_remove: [], 62 | owner_groups_to_add: [], 63 | owner_groups_to_remove: [], 64 | }; 65 | 66 | if (props.owner) { 67 | roleMembers.owner_groups_to_remove = [props.group?.id ?? '']; 68 | } else { 69 | roleMembers.groups_to_remove = [props.group?.id ?? '']; 70 | } 71 | 72 | putGroupUsers.mutate({ 73 | body: roleMembers, 74 | pathParams: {roleId: props.role?.id ?? ''}, 75 | }); 76 | }; 77 | 78 | return ( 79 | props.setOpen(false)}> 80 | Remove Role Members 81 | 82 | {requestError != '' ? {requestError} : null} 83 | 84 | Are you sure you want to remove all role {props.role.name} members from{' '} 85 | {GROUP_TYPE_ID_TO_LABELS[props.group.type].toLowerCase()} {props.group.name}{' '} 86 | {props.owner ? 'ownership' : 'membership'}? 87 | 88 | 89 | 90 | 91 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /api/access_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from typing import Any 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | # Define constants for AccessConfig JSON keys 9 | BACKEND = "BACKEND" 10 | NAME_VALIDATION_PATTERN = "NAME_VALIDATION_PATTERN" 11 | NAME_VALIDATION_ERROR = "NAME_VALIDATION_ERROR" 12 | 13 | 14 | class UndefinedConfigKeyError(Exception): 15 | def __init__(self, key: str, config: dict[str, Any]): 16 | super().__init__(f"'{key}' is not a defined config value in: {sorted(config.keys())}") 17 | 18 | 19 | class ConfigFileNotFoundError(Exception): 20 | def __init__(self, file_path: str): 21 | super().__init__(f"Config override file not found: {file_path}") 22 | 23 | 24 | class ConfigValidationError(Exception): 25 | def __init__(self, error: str): 26 | super().__init__(f"Config validation failed: {error}") 27 | 28 | 29 | class AccessConfig: 30 | def __init__(self, name_pattern: str, name_validation_error: str): 31 | self.name_pattern = name_pattern 32 | self.name_validation_error = name_validation_error 33 | 34 | 35 | def _get_config_value(config: dict[str, Any], key: str) -> Any: 36 | if key in config: 37 | return config[key] 38 | else: 39 | raise UndefinedConfigKeyError(key, config) 40 | 41 | 42 | def _validate_override_config(config: dict[str, Any]) -> None: 43 | if (NAME_VALIDATION_PATTERN in config) != (NAME_VALIDATION_ERROR in config): 44 | raise ConfigValidationError( 45 | f"If either {NAME_VALIDATION_PATTERN} or {NAME_VALIDATION_ERROR} is present, the other must also be present." 46 | ) 47 | 48 | 49 | def _merge_override_config(config: dict[str, Any], top_level_dir: str) -> None: 50 | access_config_file = os.getenv("ACCESS_CONFIG_FILE") 51 | if access_config_file: 52 | override_config_path = os.path.join(top_level_dir, "config", access_config_file) 53 | if os.path.exists(override_config_path): 54 | logger.debug(f"Loading access config override from {override_config_path}") 55 | with open(override_config_path, "r") as f: 56 | override_config = json.load(f).get(BACKEND, {}) 57 | _validate_override_config(override_config) 58 | config.update(override_config) 59 | else: 60 | raise ConfigFileNotFoundError(str(override_config_path)) 61 | 62 | 63 | def _load_default_config(top_level_dir: str) -> dict[str, Any]: 64 | default_config_path = os.path.join(top_level_dir, "config", "config.default.json") 65 | if not os.path.exists(default_config_path): 66 | raise ConfigFileNotFoundError(str(default_config_path)) 67 | with open(default_config_path, "r") as f: 68 | config = json.load(f).get(BACKEND, {}) 69 | return config 70 | 71 | 72 | def _load_access_config() -> AccessConfig: 73 | top_level_dir = os.path.dirname(os.path.dirname(__file__)) 74 | config = _load_default_config(top_level_dir) 75 | _merge_override_config(config, top_level_dir) 76 | 77 | name_pattern = _get_config_value(config, NAME_VALIDATION_PATTERN) 78 | name_validation_error = _get_config_value(config, NAME_VALIDATION_ERROR) 79 | 80 | return AccessConfig( 81 | name_pattern=name_pattern, 82 | name_validation_error=name_validation_error, 83 | ) 84 | 85 | 86 | _ACCESS_CONFIG = None 87 | 88 | 89 | def get_access_config() -> AccessConfig: 90 | global _ACCESS_CONFIG 91 | if _ACCESS_CONFIG is None: 92 | _ACCESS_CONFIG = _load_access_config() 93 | return _ACCESS_CONFIG 94 | 95 | 96 | __all__ = ["get_access_config", "AccessConfig"] 97 | -------------------------------------------------------------------------------- /src/config/loadAccessConfig.js: -------------------------------------------------------------------------------- 1 | /* 2 | * JS used in `vite.config.ts` to load `ACCESS_CONFIG` as a global variable in the frontend 3 | * If you want to use `AccessConfig` in the frontend, use `accessConfig` in `accessConfig.ts` 4 | * */ 5 | 6 | import fs from 'fs'; 7 | import path from 'path'; 8 | 9 | const ACCESS_TIME_LABELS = 'ACCESS_TIME_LABELS'; 10 | const DEFAULT_ACCESS_TIME = 'DEFAULT_ACCESS_TIME'; 11 | const FRONTEND = 'FRONTEND'; 12 | const NAME_VALIDATION_PATTERN = 'NAME_VALIDATION_PATTERN'; 13 | const NAME_VALIDATION_ERROR = 'NAME_VALIDATION_ERROR'; 14 | 15 | class UndefinedConfigError extends Error { 16 | constructor(key, obj) { 17 | const message = `'${key}' is not a defined config value in AccessConfig`; 18 | super(message); 19 | } 20 | } 21 | 22 | class AccessConfigValidationError extends Error { 23 | constructor(message) { 24 | super(message); 25 | } 26 | } 27 | 28 | class ConfigFileNotFoundError extends Error { 29 | constructor(filePath) { 30 | const message = `Config override file not found: ${filePath}`; 31 | super(message); 32 | } 33 | } 34 | 35 | function getConfig(obj, key) { 36 | if (key in obj) { 37 | return obj[key]; 38 | } else { 39 | throw new UndefinedConfigError(String(key), obj); 40 | } 41 | } 42 | 43 | function validateConfig(accessConfig) { 44 | if (ACCESS_TIME_LABELS in accessConfig && typeof accessConfig[ACCESS_TIME_LABELS] !== 'object') { 45 | throw new AccessConfigValidationError(`${ACCESS_TIME_LABELS} must be a dictionary`); 46 | } 47 | 48 | if (DEFAULT_ACCESS_TIME in accessConfig) { 49 | const defaultAccessTime = parseInt(getConfig(accessConfig, DEFAULT_ACCESS_TIME), 10); 50 | if (isNaN(defaultAccessTime) || !getConfig(accessConfig, ACCESS_TIME_LABELS).hasOwnProperty(defaultAccessTime)) { 51 | throw new AccessConfigValidationError(`${DEFAULT_ACCESS_TIME} must be a valid key in ${ACCESS_TIME_LABELS}`); 52 | } 53 | } 54 | } 55 | 56 | function validate_override_config(overrideConfig) { 57 | if (NAME_VALIDATION_PATTERN in overrideConfig && !(NAME_VALIDATION_ERROR in overrideConfig)) { 58 | throw new AccessConfigValidationError( 59 | `If ${NAME_VALIDATION_PATTERN} is present, ${NAME_VALIDATION_ERROR} must also be overridden.`, 60 | ); 61 | } 62 | } 63 | 64 | function loadOverrideConfig(accessConfig) { 65 | const envConfigPath = process.env.ACCESS_CONFIG_FILE 66 | ? path.resolve(__dirname, '../../config', process.env.ACCESS_CONFIG_FILE) 67 | : null; 68 | if (envConfigPath) { 69 | if (fs.existsSync(envConfigPath)) { 70 | const envConfig = JSON.parse(fs.readFileSync(envConfigPath, 'utf8')); 71 | if (FRONTEND in envConfig) { 72 | const frontendConfig = getConfig(envConfig, FRONTEND); 73 | validate_override_config(frontendConfig); 74 | Object.assign(accessConfig, frontendConfig); 75 | } 76 | } else { 77 | throw new ConfigFileNotFoundError(envConfigPath); 78 | } 79 | } 80 | return accessConfig; 81 | } 82 | 83 | function loadDefaultConfig() { 84 | const defaultConfigPath = path.resolve(__dirname, '../../config/config.default.json'); 85 | const defaultConfig = JSON.parse(fs.readFileSync(defaultConfigPath, 'utf8')); 86 | 87 | return getConfig(defaultConfig, FRONTEND); 88 | } 89 | 90 | export function loadAccessConfig() { 91 | try { 92 | let accessConfig = loadDefaultConfig(); 93 | accessConfig = loadOverrideConfig(accessConfig); 94 | 95 | validateConfig(accessConfig); 96 | 97 | return JSON.stringify(accessConfig); 98 | } catch (error) { 99 | console.error('Error loading access configuration:', error); 100 | throw error; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /api/views/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from api.views.schemas.access_requests import ( 2 | CreateAccessRequestSchema, 3 | ResolveAccessRequestSchema, 4 | ) 5 | from api.views.schemas.audit_logs import AuditLogSchema, EventType 6 | from api.views.schemas.core_schemas import ( 7 | AccessRequestSchema, 8 | AppGroupLifecyclePluginConfigPropertySchema, 9 | AppGroupLifecyclePluginMetadataSchema, 10 | AppGroupLifecyclePluginStatusPropertySchema, 11 | AppGroupSchema, 12 | AppSchema, 13 | AppTagMapSchema, 14 | OktaGroupSchema, 15 | OktaGroupTagMapSchema, 16 | OktaUserGroupMemberSchema, 17 | OktaUserSchema, 18 | PolymorphicGroupSchema, 19 | RoleGroupMapSchema, 20 | RoleGroupSchema, 21 | RoleRequestSchema, 22 | TagSchema, 23 | ) 24 | from api.views.schemas.delete_message import DeleteMessageSchema 25 | from api.views.schemas.group_memberships import GroupMemberSchema 26 | from api.views.schemas.metrics import MetricsSchema 27 | from api.views.schemas.pagination import ( 28 | AccessRequestPaginationSchema, 29 | AppPaginationSchema, 30 | AuditOrderBy, 31 | GroupPaginationSchema, 32 | GroupRoleAuditPaginationSchema, 33 | RolePaginationSchema, 34 | RoleRequestPaginationSchema, 35 | SearchAccessRequestPaginationRequestSchema, 36 | SearchAuditPaginationRequestSchema, 37 | SearchGroupPaginationRequestSchema, 38 | SearchGroupRoleAuditPaginationRequestSchema, 39 | SearchPaginationRequestSchema, 40 | SearchRolePaginationRequestSchema, 41 | SearchRoleRequestPaginationRequestSchema, 42 | SearchUserGroupAuditPaginationRequestSchema, 43 | TagPaginationSchema, 44 | UserGroupAuditPaginationSchema, 45 | UserPaginationSchema, 46 | ) 47 | from api.views.schemas.role_memberships import RoleMemberSchema 48 | from api.views.schemas.role_requests import CreateRoleRequestSchema, ResolveRoleRequestSchema 49 | 50 | __all__ = [ 51 | "AccessRequestPaginationSchema", 52 | "AccessRequestSchema", 53 | "AppGroupLifecyclePluginConfigPropertySchema", 54 | "AppGroupLifecyclePluginMetadataSchema", 55 | "AppGroupLifecyclePluginStatusPropertySchema", 56 | "AppGroupSchema", 57 | "AppPaginationSchema", 58 | "AppSchema", 59 | "AppTagMapSchema", 60 | "AuditLogSchema", 61 | "AuditOrderBy", 62 | "CreateAccessRequestSchema", 63 | "CreateRoleRequestSchema", 64 | "DeleteMessageSchema", 65 | "EventType", 66 | "GroupMemberSchema", 67 | "GroupPaginationSchema", 68 | "GroupRoleAuditPaginationSchema", 69 | "MetricsSchema", 70 | "OktaGroupSchema", 71 | "OktaGroupTagMapSchema", 72 | "OktaUserGroupMemberSchema", 73 | "OktaUserSchema", 74 | "PolymorphicGroupSchema", 75 | "ResolveAccessRequestSchema", 76 | "ResolveRoleRequestSchema", 77 | "RoleGroupMapSchema", 78 | "RoleGroupSchema", 79 | "RoleMemberSchema", 80 | "RolePaginationSchema", 81 | "RoleRequestPaginationSchema", 82 | "RoleRequestSchema", 83 | "SearchAccessRequestPaginationRequestSchema", 84 | "SearchAuditPaginationRequestSchema", 85 | "SearchGroupPaginationRequestSchema", 86 | "SearchGroupRoleAuditPaginationRequestSchema", 87 | "SearchPaginationRequestSchema", 88 | "SearchRolePaginationRequestSchema", 89 | "SearchRoleRequestPaginationRequestSchema", 90 | "SearchUserGroupAuditPaginationRequestSchema", 91 | "TagPaginationSchema", 92 | "TagSchema", 93 | "UserGroupAuditPaginationSchema", 94 | "UserPaginationSchema", 95 | ] 96 | 97 | # Monkeypatch marshmallow to use rfc822 format for datetime, so timezone offset is included 98 | from marshmallow.fields import DateTime 99 | 100 | DateTime.DEFAULT_FORMAT = "rfc822" 101 | -------------------------------------------------------------------------------- /api/operations/delete_user.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | from sqlalchemy.orm import ( 5 | joinedload, 6 | ) 7 | 8 | from api.extensions import db 9 | from api.models import AccessRequest, AccessRequestStatus, OktaGroup, OktaUser, OktaUserGroupMember 10 | from api.operations import RejectAccessRequest 11 | from api.services import okta 12 | 13 | 14 | class DeleteUser: 15 | def __init__(self, *, user: OktaUser | str, sync_to_okta: bool = True, current_user_id: Optional[str] = None): 16 | if isinstance(user, str): 17 | self.user = OktaUser.query.filter(OktaUser.id == user).first() 18 | else: 19 | self.user = user 20 | 21 | self.sync_to_okta = sync_to_okta 22 | 23 | self.current_user_id = getattr( 24 | OktaUser.query.filter(OktaUser.deleted_at.is_(None)).filter(OktaUser.id == current_user_id).first(), 25 | "id", 26 | None, 27 | ) 28 | 29 | def execute(self) -> None: 30 | # Run asychronously to parallelize Okta API requests 31 | return asyncio.run(self._execute()) 32 | 33 | async def _execute(self) -> None: 34 | # Create a list of okta asyncio tasks to wait to completion on at the end of this function 35 | okta_tasks = [] 36 | 37 | if self.user.deleted_at is None: 38 | self.user.deleted_at = db.func.now() 39 | 40 | # End all user memberships including group memberships via a role 41 | group_access_query = OktaUserGroupMember.query.filter( 42 | db.or_( 43 | OktaUserGroupMember.ended_at.is_(None), 44 | OktaUserGroupMember.ended_at > db.func.now(), 45 | ) 46 | ).filter(OktaUserGroupMember.user_id == self.user.id) 47 | 48 | if self.sync_to_okta: 49 | # Don't sync group access changes back to Okta for unmanaged groups 50 | managed_group_access_query = ( 51 | group_access_query.options(joinedload(OktaUserGroupMember.group)) 52 | .join(OktaUserGroupMember.group) 53 | .filter(OktaGroup.is_managed.is_(True)) 54 | ) 55 | # Remove user from group membership in Okta 56 | group_memberships_to_remove_ids = [ 57 | m.group_id for m in managed_group_access_query.filter(OktaUserGroupMember.is_owner.is_(False)).all() 58 | ] 59 | 60 | for group_id in group_memberships_to_remove_ids: 61 | okta_tasks.append(asyncio.create_task(okta.async_remove_user_from_group(group_id, self.user.id))) 62 | 63 | # Remove user from group ownerships in Okta 64 | group_ownerships_to_remove_ids = [ 65 | m.group_id for m in managed_group_access_query.filter(OktaUserGroupMember.is_owner.is_(True)).all() 66 | ] 67 | 68 | for group_id in group_ownerships_to_remove_ids: 69 | okta_tasks.append(asyncio.create_task(okta.async_remove_owner_from_group(group_id, self.user.id))) 70 | 71 | group_access_query.update({OktaUserGroupMember.ended_at: db.func.now()}, synchronize_session="fetch") 72 | 73 | db.session.commit() 74 | 75 | obsolete_access_requests = ( 76 | AccessRequest.query.filter(AccessRequest.requester_user_id == self.user.id) 77 | .filter(AccessRequest.status == AccessRequestStatus.PENDING) 78 | .filter(AccessRequest.resolved_at.is_(None)) 79 | .all() 80 | ) 81 | for obsolete_access_request in obsolete_access_requests: 82 | RejectAccessRequest( 83 | access_request=obsolete_access_request, 84 | rejection_reason="Closed because the requestor was deleted", 85 | current_user_id=self.current_user_id, 86 | ).execute() 87 | 88 | if len(okta_tasks) > 0: 89 | await asyncio.wait(okta_tasks) 90 | -------------------------------------------------------------------------------- /api/plugins/metrics_reporter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from typing import ContextManager, Dict, List, Optional 4 | 5 | import pluggy 6 | 7 | metrics_reporter_plugin_name = "access_metrics_reporter" 8 | hookspec = pluggy.HookspecMarker(metrics_reporter_plugin_name) 9 | hookimpl = pluggy.HookimplMarker(metrics_reporter_plugin_name) 10 | 11 | _cached_metrics_reporter_hook: Optional[pluggy.HookRelay] = None 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class MetricsReporterPluginSpec: 17 | @hookspec 18 | def record_counter( 19 | self, 20 | metric_name: str, 21 | value: float = 1.0, 22 | tags: Optional[Dict[str, str]] = None, 23 | monotonic: bool = True, 24 | ) -> None: 25 | """ 26 | Record a counter metric value. 27 | 28 | Args: 29 | metric_name: The metric name 30 | value: The value to add (default 1.0) 31 | tags: Optional tags 32 | monotonic: If True, counter only increases. If False, can decrease. 33 | """ 34 | 35 | @hookspec 36 | def record_gauge( 37 | self, 38 | metric_name: str, 39 | value: float, 40 | tags: Optional[Dict[str, str]] = None, 41 | ) -> None: 42 | """Record a gauge metric value (snapshot/current value).""" 43 | 44 | @hookspec 45 | def record_histogram( 46 | self, 47 | metric_name: str, 48 | value: float, 49 | tags: Optional[Dict[str, str]] = None, 50 | buckets: Optional[List[float]] = None, 51 | ) -> None: 52 | """ 53 | Record a value in a histogram/distribution. 54 | 55 | Args: 56 | metric_name: The metric name 57 | value: The value to record 58 | tags: Optional tags 59 | buckets: Optional bucket boundaries for the histogram 60 | """ 61 | 62 | @hookspec 63 | def record_summary( 64 | self, 65 | metric_name: str, 66 | value: float, 67 | tags: Optional[Dict[str, str]] = None, 68 | ) -> None: 69 | """ 70 | Record a value for summary statistics (percentiles, min, max, etc). 71 | This is similar to histogram but may be implemented differently by backends. 72 | """ 73 | 74 | @hookspec 75 | def batch_metrics(self) -> ContextManager[None]: 76 | """ 77 | Context manager for batching multiple metric operations. 78 | 79 | Returns a context manager that batches metric operations for efficiency. 80 | Particularly useful for HTTP-based backends to reduce network calls. 81 | 82 | Example: 83 | with metrics.batch_metrics(): 84 | metrics.record_counter("requests", 1) 85 | metrics.record_gauge("queue_size", 42) 86 | metrics.record_histogram("response_time", 0.123) 87 | # All metrics sent in one batch here 88 | """ 89 | return NotImplemented 90 | 91 | @hookspec 92 | def set_global_tags( 93 | self, 94 | tags: Dict[str, str], 95 | ) -> None: 96 | """Set global tags to be included with all metrics.""" 97 | 98 | @hookspec 99 | def flush(self) -> None: 100 | """Force flush any buffered metrics to the backend.""" 101 | 102 | 103 | def get_metrics_reporter_hook() -> pluggy.HookRelay: 104 | global _cached_metrics_reporter_hook 105 | 106 | if _cached_metrics_reporter_hook is not None: 107 | return _cached_metrics_reporter_hook 108 | 109 | pm = pluggy.PluginManager(metrics_reporter_plugin_name) 110 | pm.add_hookspecs(MetricsReporterPluginSpec) 111 | 112 | # Register the hook wrappers 113 | pm.register(sys.modules[__name__]) 114 | 115 | count = pm.load_setuptools_entrypoints(metrics_reporter_plugin_name) 116 | logger.debug(f"Count of loaded metrics reporter plugins: {count}") 117 | _cached_metrics_reporter_hook = pm.hook 118 | 119 | return _cached_metrics_reporter_hook 120 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test Python 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | python-test: 9 | runs-on: ubuntu-22.04 10 | strategy: 11 | matrix: 12 | python-version: ["3.13"] 13 | 14 | services: 15 | postgres: 16 | # Docker Hub image 17 | image: postgres 18 | # Provide the password for postgres 19 | env: 20 | POSTGRES_PASSWORD: postgres 21 | # Set health checks to wait until postgres has started 22 | options: >- 23 | --health-cmd pg_isready 24 | --health-interval 10s 25 | --health-timeout 5s 26 | --health-retries 5 27 | ports: 28 | # Maps tcp port 5432 on service container to the host 29 | - 5432:5432 30 | steps: 31 | - uses: actions/checkout@v6 32 | with: 33 | persist-credentials: false 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v6 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | python -m pip install tox tox-gh-actions 42 | - name: Test with tox 43 | run: tox -e test 44 | - name: Test with tox with postgresql 45 | run: tox -e test-with-postgresql 46 | - name: Test installing the notifications plugin 47 | run: | 48 | # Install the notifications plugin from examples/plugins/notifications 49 | if [ -d "examples/plugins/notifications" ]; then 50 | python -m pip install ./examples/plugins/notifications 51 | else 52 | echo "Notifications plugin directory not found; skipping installation" 53 | fi 54 | - name: Test installing the Slack notifications plugin 55 | run: | 56 | # Install the Slack notifications plugin from examples/plugins/notifications_slack 57 | if [ -d "examples/plugins/notifications_slack" ]; then 58 | python -m pip install ./examples/plugins/notifications_slack 59 | else 60 | echo "Slack notifications plugin directory not found; skipping installation" 61 | fi 62 | - name: Test installing the conditional access plugin 63 | run: | 64 | # Install the conditional access plugin from examples/plugins/conditional_access 65 | if [ -d "examples/plugins/conditional_access" ]; then 66 | python -m pip install ./examples/plugins/conditional_access 67 | else 68 | echo "Conditional access plugin directory not found; skipping installation" 69 | fi 70 | - name: Test installing the health check plugin 71 | run: | 72 | # Install the health check plugin from examples/plugins/health_check_plugin 73 | if [ -d "examples/plugins/health_check_plugin" ]; then 74 | python -m pip install ./examples/plugins/health_check_plugin 75 | else 76 | echo "Health check plugin directory not found; skipping installation" 77 | fi 78 | - name: Test installing the Datadog metrics reporter plugin 79 | run: | 80 | # Install the Datadog metrics reporter plugin from examples/plugins/datadog_metrics_reporter 81 | if [ -d "examples/plugins/datadog_metrics_reporter" ]; then 82 | python -m pip install ./examples/plugins/datadog_metrics_reporter 83 | else 84 | echo "Datadog metrics reporter plugin directory not found; skipping installation" 85 | fi 86 | - name: Test installing the app group lifecycle audit logger plugin 87 | run: | 88 | # Install the app group lifecycle audit logger plugin from examples/plugins/app_group_lifecycle_audit_logger 89 | if [ -d "examples/plugins/app_group_lifecycle_audit_logger" ]; then 90 | python -m pip install ./examples/plugins/app_group_lifecycle_audit_logger 91 | else 92 | echo "App group lifecycle audit logger plugin directory not found; skipping installation" 93 | fi -------------------------------------------------------------------------------- /src/components/AccessHistory.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Paper from '@mui/material/Paper'; 3 | import Typography from '@mui/material/Typography'; 4 | import Box from '@mui/material/Box'; 5 | import {OktaUserGroupMember, RoleGroupMap} from '../api/apiSchemas'; 6 | import AccessHistoryTable from './AccessHistoryTable'; 7 | import InfoOutlined from '@mui/icons-material/InfoOutlined'; 8 | import Accordion from '@mui/material/Accordion'; 9 | import AccordionSummary from '@mui/material/AccordionSummary'; 10 | import AccordionDetails from '@mui/material/AccordionDetails'; 11 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 12 | 13 | type AccessAuditEntry = OktaUserGroupMember | RoleGroupMap; 14 | 15 | type SubjectType = 'user' | 'role'; 16 | 17 | interface AccessHistoryProps { 18 | subjectType: SubjectType; 19 | subjectName: string; 20 | groupName: string; 21 | auditHistory: AccessAuditEntry[]; 22 | alternativeRoleMappings?: RoleGroupMap[]; 23 | } 24 | 25 | export default function AccessHistory({ 26 | subjectType, 27 | subjectName, 28 | groupName, 29 | auditHistory, 30 | alternativeRoleMappings, 31 | }: AccessHistoryProps) { 32 | const currentAccess = auditHistory.filter( 33 | (audit) => audit.ended_at == null || (audit.ended_at && new Date(audit.ended_at) > new Date()), 34 | ); 35 | const pastAccess = auditHistory.filter((audit) => audit.ended_at != null && new Date(audit.ended_at) <= new Date()); 36 | // Info card for empty state 37 | if (currentAccess.length === 0 && pastAccess.length === 0) { 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | No prior access history 47 | 48 | 49 | {subjectType === 'user' 50 | ? `This user has never had access to this group. Approving this request will grant access for the first time.` 51 | : `This role has never had access to this group. Approving this request will grant access for the first time.`} 52 | 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | return ( 60 | 61 | 62 | }> 63 | 64 | {`${subjectName} Access History to ${groupName}`} 65 | 66 | 67 | 68 | {/* Current Access Section */} 69 | 70 | {`Current Access${currentAccess.length > 0 ? ` (${currentAccess.length} active membership${currentAccess.length !== 1 ? 's' : ''})` : ''}`} 71 | 72 | 78 |
    79 | {/* Past Access Section */} 80 | 81 | 82 | {`Past Access${pastAccess.length > 0 ? ` (${pastAccess.length} previous membership${pastAccess.length !== 1 ? 's' : ''})` : ''}`} 83 | 84 | 90 | 91 |
    92 |
    93 |
    94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/pages/groups/Delete.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import Button from '@mui/material/Button'; 4 | import IconButton from '@mui/material/IconButton'; 5 | import Dialog from '@mui/material/Dialog'; 6 | import DialogActions from '@mui/material/DialogActions'; 7 | import DialogContent from '@mui/material/DialogContent'; 8 | import DialogContentText from '@mui/material/DialogContentText'; 9 | import DialogTitle from '@mui/material/DialogTitle'; 10 | import DeleteIcon from '@mui/icons-material/DeleteForever'; 11 | import Alert from '@mui/material/Alert'; 12 | import CircularProgress from '@mui/material/CircularProgress'; 13 | 14 | import {useDeleteGroupById, DeleteGroupByIdError, DeleteGroupByIdVariables} from '../../api/apiComponents'; 15 | import {PolymorphicGroup, AppGroup, OktaUser} from '../../api/apiSchemas'; 16 | import {canManageGroup} from '../../authorization'; 17 | 18 | interface GroupDialogProps { 19 | setOpen(open: boolean): any; 20 | group: PolymorphicGroup; 21 | } 22 | 23 | const GROUP_TYPE_ID_TO_LABELS: Record = { 24 | okta_group: 'Group', 25 | app_group: 'App Group', 26 | role_group: 'Role', 27 | } as const; 28 | 29 | function GroupDialog(props: GroupDialogProps) { 30 | const navigate = useNavigate(); 31 | 32 | const defaultGroupType = props.group?.type; 33 | const [requestError, setRequestError] = React.useState(''); 34 | const [submitting, setSubmitting] = React.useState(false); 35 | 36 | const complete = ( 37 | deletedGroup: PolymorphicGroup | undefined, 38 | error: DeleteGroupByIdError | null, 39 | variables: DeleteGroupByIdVariables, 40 | context: any, 41 | ) => { 42 | setSubmitting(false); 43 | if (error != null) { 44 | setRequestError(error.payload.toString()); 45 | } else { 46 | props.setOpen(false); 47 | switch (props.group.type) { 48 | case 'app_group': 49 | navigate('/apps/' + encodeURIComponent((props.group as AppGroup).app?.name ?? '')); 50 | break; 51 | case 'role_group': 52 | navigate('/roles/'); 53 | break; 54 | default: 55 | navigate('/groups/'); 56 | break; 57 | } 58 | } 59 | }; 60 | 61 | const deleteGroup = useDeleteGroupById({ 62 | onSettled: complete, 63 | }); 64 | 65 | const submit = () => { 66 | setSubmitting(true); 67 | deleteGroup.mutate({ 68 | pathParams: {groupId: props.group?.id ?? ''}, 69 | }); 70 | }; 71 | 72 | return ( 73 | props.setOpen(false)}> 74 | Delete {GROUP_TYPE_ID_TO_LABELS[props.group.type]} 75 | 76 | {requestError != '' ? {requestError} : null} 77 | 78 | Are you sure you want to delete {GROUP_TYPE_ID_TO_LABELS[props.group.type].toLowerCase()}{' '} 79 | "{props.group.name}"? 80 | 81 | 82 | 83 | 84 | 87 | 88 | 89 | ); 90 | } 91 | 92 | interface DeleteGroupProps { 93 | currentUser: OktaUser; 94 | group: PolymorphicGroup; 95 | } 96 | 97 | export default function DeleteGroup(props: DeleteGroupProps) { 98 | const [open, setOpen] = React.useState(false); 99 | 100 | if ( 101 | props.group.deleted_at != null || 102 | !canManageGroup(props.currentUser, props.group) || 103 | (props.group.type == 'app_group' && (props.group as AppGroup).is_owner) || 104 | !props.group.is_managed 105 | ) { 106 | return null; 107 | } 108 | 109 | return ( 110 | <> 111 | setOpen(true)}> 112 | 113 | 114 | {open ? : null} 115 | 116 | ); 117 | } 118 | --------------------------------------------------------------------------------