Skip to content

Commit c86980e

Browse files
authored
App group lifecycle plugin (#351)
1 parent d25972c commit c86980e

37 files changed

+4307
-56
lines changed

.github/workflows/ci.yml

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,35 +43,51 @@ jobs:
4343
run: tox -e test
4444
- name: Test with tox with postgresql
4545
run: tox -e test-with-postgresql
46-
- name: Test installing notifications plugin
46+
- name: Test installing the notifications plugin
4747
run: |
4848
# Install the notifications plugin from examples/plugins/notifications
4949
if [ -d "examples/plugins/notifications" ]; then
5050
python -m pip install ./examples/plugins/notifications
5151
else
52-
echo "Notifications plugin directory not found, skipping installation"
52+
echo "Notifications plugin directory not found; skipping installation"
5353
fi
54-
- name: Test installing slack notifications plugin
54+
- name: Test installing the Slack notifications plugin
5555
run: |
56-
# Install the slack notifications plugin from examples/plugins/notifications
57-
if [ -d "examples/plugins/notifications/notifications_slack" ]; then
58-
python -m pip install ./examples/plugins/notifications/notifications_slack
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
5959
else
60-
echo "Slack notifications plugin directory not found, skipping installation"
60+
echo "Slack notifications plugin directory not found; skipping installation"
6161
fi
62-
- name: Test installing conditional access plugin
62+
- name: Test installing the conditional access plugin
6363
run: |
64-
# Install the slack notifications plugin from examples/plugins/notifications
64+
# Install the conditional access plugin from examples/plugins/conditional_access
6565
if [ -d "examples/plugins/conditional_access" ]; then
6666
python -m pip install ./examples/plugins/conditional_access
6767
else
68-
echo "Conditional access plugin directory not found, skipping installation"
68+
echo "Conditional access plugin directory not found; skipping installation"
6969
fi
70-
- name: Test installing health check plugin
70+
- name: Test installing the health check plugin
7171
run: |
72-
# Install the slack notifications plugin from examples/plugins/notifications
72+
# Install the health check plugin from examples/plugins/health_check_plugin
7373
if [ -d "examples/plugins/health_check_plugin" ]; then
7474
python -m pip install ./examples/plugins/health_check_plugin
7575
else
76-
echo "Health check plugin directory not found, skipping installation"
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"
7793
fi

api/app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
exception_views,
2929
groups_views,
3030
health_check_views,
31+
plugins_views,
3132
role_requests_views,
3233
roles_views,
3334
tags_views,
@@ -202,6 +203,7 @@ def add_headers(response: Response) -> ResponseReturnValue:
202203
app.cli.add_command(manage.fix_unmanaged_groups)
203204
app.cli.add_command(manage.fix_role_memberships)
204205
app.cli.add_command(manage.notify)
206+
app.cli.add_command(manage.sync_app_group_memberships)
205207

206208
# Register dynamically loaded commands
207209
flask_commands = entry_points(group="flask.commands")
@@ -218,6 +220,19 @@ def add_headers(response: Response) -> ResponseReturnValue:
218220
###########################################
219221
docs.init_app(app)
220222

223+
##########################################
224+
# Validate plugins
225+
##########################################
226+
# Validate app group lifecycle plugins at startup to ensure uniqueness
227+
# and proper registration
228+
try:
229+
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_plugins
230+
231+
_ = get_app_group_lifecycle_plugins()
232+
except Exception:
233+
logger.exception("Failed to validate app group lifecycle plugins.")
234+
raise
235+
221236
##########################################
222237
# Blueprint Registration
223238
##########################################
@@ -249,5 +264,7 @@ def add_headers(response: Response) -> ResponseReturnValue:
249264
tags_views.register_docs()
250265
app.register_blueprint(webhook_views.bp)
251266
app.register_blueprint(bugs_views.bp)
267+
app.register_blueprint(plugins_views.bp)
268+
plugins_views.register_docs()
252269

253270
return app

api/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@
1010
OKTA_API_TOKEN = os.getenv("OKTA_API_TOKEN")
1111
# The Group Owners API is only available to Okta plans with IGA enabled
1212
# Disable by default, but allow opt-in to sync group owners to Okta if desired
13-
OKTA_USE_GROUP_OWNERS_API = os.getenv("OKTA_USE_GROUP_OWNERS_API", "False") == "True"
13+
OKTA_USE_GROUP_OWNERS_API = os.getenv("OKTA_USE_GROUP_OWNERS_API", "false").lower() == "true"
1414
CURRENT_OKTA_USER_EMAIL = os.getenv("CURRENT_OKTA_USER_EMAIL", "[email protected]")
1515

1616
# Optional env var to set a custom Okta Group Profile attribute for Access management inclusion/exclusion
1717
OKTA_GROUP_PROFILE_CUSTOM_ATTR = os.getenv("OKTA_GROUP_PROFILE_CUSTOM_ATTR")
1818

1919
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URI")
2020
SQLALCHEMY_TRACK_MODIFICATIONS = False
21-
SQLALCHEMY_ECHO = ENV == "development" # or ENV == "test"
21+
SQLALCHEMY_ECHO = os.getenv("SQLALCHEMY_ECHO", str(ENV == "development")).lower() == "true"
2222

2323
# Attributes to display in the user page
2424
USER_DISPLAY_CUSTOM_ATTRIBUTES = os.getenv("USER_DISPLAY_CUSTOM_ATTRIBUTES", "Title,Manager")
@@ -79,7 +79,7 @@ def default_user_search() -> list[str]:
7979
DATABASE_USER = os.getenv("DATABASE_USER", "root")
8080
DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD", "")
8181
DATABASE_NAME = os.getenv("DATABASE_NAME", "access")
82-
DATABASE_USES_PUBLIC_IP = os.getenv("DATABASE_USES_PUBLIC_IP", "False") == "True"
82+
DATABASE_USES_PUBLIC_IP = os.getenv("DATABASE_USES_PUBLIC_IP", "false").lower() == "true"
8383

8484
FLASK_SENTRY_DSN = os.getenv("FLASK_SENTRY_DSN")
8585
REACT_SENTRY_DSN = os.getenv("REACT_SENTRY_DSN")

api/manage.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import List
2+
13
import click
24
from flask.cli import with_appcontext
35

@@ -123,8 +125,8 @@ def _init_builtin_apps(admin_okta_user_email: str) -> None:
123125
)
124126
@with_appcontext
125127
def sync(sync_groups_authoritatively: bool, sync_group_memberships_authoritatively: bool) -> None:
126-
from sentry_sdk import start_transaction
127128
from flask import current_app
129+
from sentry_sdk import start_transaction
128130

129131
from api.syncer import (
130132
expire_access_requests,
@@ -206,3 +208,39 @@ def notify(owner: bool, role_owner: bool) -> None:
206208
expiring_access_notifications_role_owner()
207209
else:
208210
expiring_access_notifications_user()
211+
212+
213+
@click.command("sync-app-group-memberships")
214+
@with_appcontext
215+
def sync_app_group_memberships() -> None:
216+
"""Invoke the periodic membership sync hook for all apps with app group lifecycle plugins configured."""
217+
from api.extensions import db
218+
from api.models import App
219+
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_hook
220+
221+
click.echo("Starting app group lifecycle plugin sync")
222+
223+
# Find all apps with a plugin configured
224+
apps: List[App] = (
225+
App.query.filter(App.deleted_at.is_(None)).filter(App.app_group_lifecycle_plugin.isnot(None)).all()
226+
)
227+
228+
if len(apps) == 0:
229+
click.echo("No apps with app group lifecycle plugins configured")
230+
return
231+
232+
click.echo(f"Found {len(apps)} app(s) with plugins configured")
233+
234+
hook = get_app_group_lifecycle_hook()
235+
236+
for app in apps:
237+
click.echo(f"Syncing app '{app.name}' (plugin: {app.app_group_lifecycle_plugin})")
238+
try:
239+
hook.sync_all_group_membership(session=db.session, app=app, plugin_id=app.app_group_lifecycle_plugin)
240+
db.session.commit()
241+
click.echo(f" ✓ Synced app '{app.name}'")
242+
except Exception as e:
243+
db.session.rollback()
244+
click.echo(f" ✗ Failed to sync app '{app.name}': {e}", err=True)
245+
246+
click.echo("Completed app group lifecycle plugin sync")

api/models/core_models.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
from enum import StrEnum
33
from typing import Any, Callable, Dict, List, Optional
44

5-
from api import config
65
from sqlalchemy.dialects.postgresql import JSONB
76
from sqlalchemy.orm import Mapped, mapped_column, validates
87
from sqlalchemy.sql import expression
98
from sqlalchemy_json import mutable_json_type
109

10+
from api import config
1111
from api.extensions import db
1212

1313

@@ -276,7 +276,7 @@ class OktaGroup(db.Model):
276276
server_default="{}",
277277
)
278278

279-
# A JSON field for Group plugin integrations in the form of {"unique_plugin_name":{plugin_data},}
279+
# A JSON field for Group plugin integrations in the form of {"unique_plugin_name": plugin_data}
280280
# https://github.com/edelooff/sqlalchemy-json
281281
# https://amercader.net/blog/beware-of-json-fields-in-sqlalchemy/
282282
plugin_data: Mapped[Dict[str, Any]] = mapped_column(
@@ -639,6 +639,18 @@ class App(db.Model):
639639
name: Mapped[str] = mapped_column(db.Unicode(255), nullable=False)
640640
description: Mapped[str] = mapped_column(db.Unicode(1024), nullable=False, default="")
641641

642+
# Optional plugin ID for managing app group lifecycle
643+
app_group_lifecycle_plugin: Mapped[Optional[str]] = mapped_column(db.Unicode(255))
644+
645+
# A JSON field for App plugin integrations in the form of {"unique_plugin_name": plugin_data }
646+
# https://github.com/edelooff/sqlalchemy-json
647+
# https://amercader.net/blog/beware-of-json-fields-in-sqlalchemy/
648+
plugin_data: Mapped[Dict[str, Any]] = mapped_column(
649+
mutable_json_type(dbtype=db.JSON().with_variant(JSONB, "postgresql"), nested=True),
650+
nullable=False,
651+
server_default="{}",
652+
)
653+
642654
app_groups: Mapped[List[AppGroup]] = db.relationship("AppGroup", back_populates="app", lazy="raise_on_sql")
643655

644656
active_app_groups: Mapped[List[AppGroup]] = db.relationship(

api/operations/create_group.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from api.extensions import db
88
from api.models import App, AppGroup, AppTagMap, OktaGroup, OktaGroupTagMap, OktaUser, RoleGroup, Tag
9+
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_hook, get_app_group_lifecycle_plugin_to_invoke
910
from api.services import okta
1011
from api.views.schemas import AuditLogSchema, EventType
1112

@@ -91,6 +92,19 @@ def execute(self, *, _group: Optional[T] = None) -> T:
9192
)
9293
db.session.commit()
9394

95+
# Invoke app group lifecycle plugin hook, if configured
96+
plugin_id = get_app_group_lifecycle_plugin_to_invoke(self.group)
97+
if plugin_id is not None:
98+
try:
99+
hook = get_app_group_lifecycle_hook()
100+
hook.group_created(session=db.session, group=self.group, plugin_id=plugin_id)
101+
db.session.commit()
102+
except Exception:
103+
current_app.logger.exception(
104+
f"Failed to invoke group_created hook for group {self.group.id} with plugin '{plugin_id}'"
105+
)
106+
db.session.rollback()
107+
94108
# Audit logging
95109
email = None
96110
if self.current_user_id is not None:

api/operations/delete_group.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
RoleGroupMap,
1919
)
2020
from api.operations.reject_access_request import RejectAccessRequest
21+
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_hook, get_app_group_lifecycle_plugin_to_invoke
2122
from api.services import okta
2223
from api.views.schemas import AuditLogSchema, EventType
2324

@@ -234,5 +235,18 @@ async def _execute(self) -> None:
234235
)
235236
db.session.commit()
236237

238+
# Invoke app group lifecycle plugin hook, if configured
239+
plugin_id = get_app_group_lifecycle_plugin_to_invoke(self.group)
240+
if plugin_id is not None:
241+
try:
242+
hook = get_app_group_lifecycle_hook()
243+
hook.group_deleted(session=db.session, group=self.group, plugin_id=plugin_id)
244+
db.session.commit()
245+
except Exception:
246+
current_app.logger.exception(
247+
f"Failed to invoke group_deleted hook for group {self.group.id} with plugin '{plugin_id}'"
248+
)
249+
db.session.rollback()
250+
237251
if len(okta_tasks) > 0:
238252
await asyncio.wait(okta_tasks)

0 commit comments

Comments
 (0)