Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,35 +43,51 @@ jobs:
run: tox -e test
- name: Test with tox with postgresql
run: tox -e test-with-postgresql
- name: Test installing notifications plugin
- name: Test installing the notifications plugin
run: |
# Install the notifications plugin from examples/plugins/notifications
if [ -d "examples/plugins/notifications" ]; then
python -m pip install ./examples/plugins/notifications
else
echo "Notifications plugin directory not found, skipping installation"
echo "Notifications plugin directory not found; skipping installation"
fi
- name: Test installing slack notifications plugin
- name: Test installing the Slack notifications plugin
run: |
# Install the slack notifications plugin from examples/plugins/notifications
if [ -d "examples/plugins/notifications/notifications_slack" ]; then
python -m pip install ./examples/plugins/notifications/notifications_slack
# Install the Slack notifications plugin from examples/plugins/notifications_slack
if [ -d "examples/plugins/notifications_slack" ]; then
python -m pip install ./examples/plugins/notifications_slack
else
echo "Slack notifications plugin directory not found, skipping installation"
echo "Slack notifications plugin directory not found; skipping installation"
fi
- name: Test installing conditional access plugin
- name: Test installing the conditional access plugin
run: |
# Install the slack notifications plugin from examples/plugins/notifications
# Install the conditional access plugin from examples/plugins/conditional_access
if [ -d "examples/plugins/conditional_access" ]; then
python -m pip install ./examples/plugins/conditional_access
else
echo "Conditional access plugin directory not found, skipping installation"
echo "Conditional access plugin directory not found; skipping installation"
fi
- name: Test installing health check plugin
- name: Test installing the health check plugin
run: |
# Install the slack notifications plugin from examples/plugins/notifications
# Install the health check plugin from examples/plugins/health_check_plugin
if [ -d "examples/plugins/health_check_plugin" ]; then
python -m pip install ./examples/plugins/health_check_plugin
else
echo "Health check plugin directory not found, skipping installation"
echo "Health check plugin directory not found; skipping installation"
fi
- name: Test installing the Datadog metrics reporter plugin
run: |
# Install the Datadog metrics reporter plugin from examples/plugins/datadog_metrics_reporter
if [ -d "examples/plugins/datadog_metrics_reporter" ]; then
python -m pip install ./examples/plugins/datadog_metrics_reporter
else
echo "Datadog metrics reporter plugin directory not found; skipping installation"
fi
- name: Test installing the app group lifecycle audit logger plugin
run: |
# Install the app group lifecycle audit logger plugin from examples/plugins/app_group_lifecycle_audit_logger
if [ -d "examples/plugins/app_group_lifecycle_audit_logger" ]; then
python -m pip install ./examples/plugins/app_group_lifecycle_audit_logger
else
echo "App group lifecycle audit logger plugin directory not found; skipping installation"
fi
17 changes: 17 additions & 0 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
exception_views,
groups_views,
health_check_views,
plugins_views,
role_requests_views,
roles_views,
tags_views,
Expand Down Expand Up @@ -202,6 +203,7 @@ def add_headers(response: Response) -> ResponseReturnValue:
app.cli.add_command(manage.fix_unmanaged_groups)
app.cli.add_command(manage.fix_role_memberships)
app.cli.add_command(manage.notify)
app.cli.add_command(manage.sync_app_group_memberships)

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

##########################################
# Validate plugins
##########################################
# Validate app group lifecycle plugins at startup to ensure uniqueness
# and proper registration
try:
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_plugins

_ = get_app_group_lifecycle_plugins()
except Exception:
logger.exception("Failed to validate app group lifecycle plugins.")
raise

##########################################
# Blueprint Registration
##########################################
Expand Down Expand Up @@ -249,5 +264,7 @@ def add_headers(response: Response) -> ResponseReturnValue:
tags_views.register_docs()
app.register_blueprint(webhook_views.bp)
app.register_blueprint(bugs_views.bp)
app.register_blueprint(plugins_views.bp)
plugins_views.register_docs()

return app
6 changes: 3 additions & 3 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
OKTA_API_TOKEN = os.getenv("OKTA_API_TOKEN")
# The Group Owners API is only available to Okta plans with IGA enabled
# Disable by default, but allow opt-in to sync group owners to Okta if desired
OKTA_USE_GROUP_OWNERS_API = os.getenv("OKTA_USE_GROUP_OWNERS_API", "False") == "True"
OKTA_USE_GROUP_OWNERS_API = os.getenv("OKTA_USE_GROUP_OWNERS_API", "false").lower() == "true"
CURRENT_OKTA_USER_EMAIL = os.getenv("CURRENT_OKTA_USER_EMAIL", "[email protected]")

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

SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URI")
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = ENV == "development" # or ENV == "test"
SQLALCHEMY_ECHO = os.getenv("SQLALCHEMY_ECHO", str(ENV == "development")).lower() == "true"

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

FLASK_SENTRY_DSN = os.getenv("FLASK_SENTRY_DSN")
REACT_SENTRY_DSN = os.getenv("REACT_SENTRY_DSN")
Expand Down
40 changes: 39 additions & 1 deletion api/manage.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import List

import click
from flask.cli import with_appcontext

Expand Down Expand Up @@ -123,8 +125,8 @@ def _init_builtin_apps(admin_okta_user_email: str) -> None:
)
@with_appcontext
def sync(sync_groups_authoritatively: bool, sync_group_memberships_authoritatively: bool) -> None:
from sentry_sdk import start_transaction
from flask import current_app
from sentry_sdk import start_transaction

from api.syncer import (
expire_access_requests,
Expand Down Expand Up @@ -206,3 +208,39 @@ def notify(owner: bool, role_owner: bool) -> None:
expiring_access_notifications_role_owner()
else:
expiring_access_notifications_user()


@click.command("sync-app-group-memberships")
@with_appcontext
def sync_app_group_memberships() -> None:
"""Invoke the periodic membership sync hook for all apps with app group lifecycle plugins configured."""
from api.extensions import db
from api.models import App
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_hook

click.echo("Starting app group lifecycle plugin sync")

# Find all apps with a plugin configured
apps: List[App] = (
App.query.filter(App.deleted_at.is_(None)).filter(App.app_group_lifecycle_plugin.isnot(None)).all()
)

if len(apps) == 0:
click.echo("No apps with app group lifecycle plugins configured")
return

click.echo(f"Found {len(apps)} app(s) with plugins configured")

hook = get_app_group_lifecycle_hook()

for app in apps:
click.echo(f"Syncing app '{app.name}' (plugin: {app.app_group_lifecycle_plugin})")
try:
hook.sync_all_group_membership(session=db.session, app=app, plugin_id=app.app_group_lifecycle_plugin)
db.session.commit()
click.echo(f" ✓ Synced app '{app.name}'")
except Exception as e:
db.session.rollback()
click.echo(f" ✗ Failed to sync app '{app.name}': {e}", err=True)

click.echo("Completed app group lifecycle plugin sync")
16 changes: 14 additions & 2 deletions api/models/core_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from enum import StrEnum
from typing import Any, Callable, Dict, List, Optional

from api import config
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, validates
from sqlalchemy.sql import expression
from sqlalchemy_json import mutable_json_type

from api import config
from api.extensions import db


Expand Down Expand Up @@ -276,7 +276,7 @@ class OktaGroup(db.Model):
server_default="{}",
)

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

# Optional plugin ID for managing app group lifecycle
app_group_lifecycle_plugin: Mapped[Optional[str]] = mapped_column(db.Unicode(255))

# A JSON field for App plugin integrations in the form of {"unique_plugin_name": plugin_data }
# https://github.com/edelooff/sqlalchemy-json
# https://amercader.net/blog/beware-of-json-fields-in-sqlalchemy/
plugin_data: Mapped[Dict[str, Any]] = mapped_column(
mutable_json_type(dbtype=db.JSON().with_variant(JSONB, "postgresql"), nested=True),
nullable=False,
server_default="{}",
)

app_groups: Mapped[List[AppGroup]] = db.relationship("AppGroup", back_populates="app", lazy="raise_on_sql")

active_app_groups: Mapped[List[AppGroup]] = db.relationship(
Expand Down
14 changes: 14 additions & 0 deletions api/operations/create_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from api.extensions import db
from api.models import App, AppGroup, AppTagMap, OktaGroup, OktaGroupTagMap, OktaUser, RoleGroup, Tag
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_hook, get_app_group_lifecycle_plugin_to_invoke
from api.services import okta
from api.views.schemas import AuditLogSchema, EventType

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

# Invoke app group lifecycle plugin hook, if configured
plugin_id = get_app_group_lifecycle_plugin_to_invoke(self.group)
if plugin_id is not None:
try:
hook = get_app_group_lifecycle_hook()
hook.group_created(session=db.session, group=self.group, plugin_id=plugin_id)
db.session.commit()
except Exception:
current_app.logger.exception(
f"Failed to invoke group_created hook for group {self.group.id} with plugin '{plugin_id}'"
)
db.session.rollback()

# Audit logging
email = None
if self.current_user_id is not None:
Expand Down
14 changes: 14 additions & 0 deletions api/operations/delete_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
RoleGroupMap,
)
from api.operations.reject_access_request import RejectAccessRequest
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_hook, get_app_group_lifecycle_plugin_to_invoke
from api.services import okta
from api.views.schemas import AuditLogSchema, EventType

Expand Down Expand Up @@ -234,5 +235,18 @@ async def _execute(self) -> None:
)
db.session.commit()

# Invoke app group lifecycle plugin hook, if configured
plugin_id = get_app_group_lifecycle_plugin_to_invoke(self.group)
if plugin_id is not None:
try:
hook = get_app_group_lifecycle_hook()
hook.group_deleted(session=db.session, group=self.group, plugin_id=plugin_id)
db.session.commit()
except Exception:
current_app.logger.exception(
f"Failed to invoke group_deleted hook for group {self.group.id} with plugin '{plugin_id}'"
)
db.session.rollback()

if len(okta_tasks) > 0:
await asyncio.wait(okta_tasks)
37 changes: 35 additions & 2 deletions api/operations/modify_group_users.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to handle the case where members are added/removed from a role group and there's an app group associated with that role group that has an app group plugin which should be notified of changes.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from api.models.tag import coalesce_ended_at
from api.operations.constraints import CheckForReason, CheckForSelfAdd
from api.plugins import get_notification_hook
from api.plugins.app_group_lifecycle import get_app_group_lifecycle_hook, get_app_group_lifecycle_plugin_to_invoke
from api.services import okta
from api.views.schemas import AuditLogSchema, EventType

Expand Down Expand Up @@ -369,8 +370,25 @@ async def _execute(self) -> OktaGroup:
)
)

# Commit all changes so far
db.session.commit()
db.session.commit()

# Invoke app group lifecycle plugin hooks for removed members
plugin_id = get_app_group_lifecycle_plugin_to_invoke(self.group)
if plugin_id is not None and len(self.members_to_remove) > 0:
try:
hook = get_app_group_lifecycle_hook()
hook.group_members_removed(
session=db.session, group=self.group, members=self.members_to_remove, plugin_id=plugin_id
)
db.session.commit()
except Exception:
current_app.logger.exception(
f"Failed to invoke group_members_removed hook for group {self.group.id} with plugin '{plugin_id}'"
)
db.session.rollback()
else:
# Commit all changes so far
db.session.commit()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This session commit probably doesn't need to be in an else clause, but either way is fine for now.


# Mark relevant OktaUserGroupMembers as 'Should expire'
# Only relevant for the expiring groups page so not adding checks for this field anywhere else since OK if marked to expire
Expand Down Expand Up @@ -505,6 +523,21 @@ async def _execute(self) -> OktaGroup:
# Commit changes so far, so we can reference OktaUserGroupMember in approved AccessRequests
db.session.commit()

# Invoke app group lifecycle plugin hooks for added members
plugin_id = get_app_group_lifecycle_plugin_to_invoke(self.group)
if plugin_id is not None and len(self.members_to_add) > 0:
try:
hook = get_app_group_lifecycle_hook()
hook.group_members_added(
session=db.session, group=self.group, members=self.members_to_add, plugin_id=plugin_id
)
db.session.commit()
except Exception:
current_app.logger.exception(
f"Failed to invoke group_members_added hook for group {self.group.id} with plugin '{plugin_id}'"
)
db.session.rollback()

# Approve any pending access requests for access granted by this operation
pending_requests_query = (
AccessRequest.query.options(joinedload(AccessRequest.requested_group))
Expand Down
Loading