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
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
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()

# 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
49 changes: 47 additions & 2 deletions api/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,60 @@
import pluggy

from api.plugins.app_group_lifecycle import (
AppGroupLifecyclePluginConfigProperty,
AppGroupLifecyclePluginFilteringError,
AppGroupLifecyclePluginMetadata,
AppGroupLifecyclePluginSpec,
AppGroupLifecyclePluginStatusProperty,
app_group_lifecycle_plugin_name,
get_app_group_lifecycle_hook,
get_app_group_lifecycle_plugin_app_config_properties,
get_app_group_lifecycle_plugin_app_status_properties,
get_app_group_lifecycle_plugin_group_config_properties,
get_app_group_lifecycle_plugin_group_status_properties,
get_app_group_lifecycle_plugin_to_invoke,
get_app_group_lifecycle_plugins,
get_config_value,
get_status_value,
merge_app_lifecycle_plugin_data,
set_status_value,
validate_app_group_lifecycle_plugin_app_config,
validate_app_group_lifecycle_plugin_group_config,
)
from api.plugins.conditional_access import ConditionalAccessResponse, get_conditional_access_hook
from api.plugins.notifications import get_notification_hook

condtional_access_hook_impl = pluggy.HookimplMarker("access_conditional_access")
app_group_lifecycle_hook_impl = pluggy.HookimplMarker("access_app_group_lifecycle")
conditional_access_hook_impl = pluggy.HookimplMarker("access_conditional_access")
notification_hook_impl = pluggy.HookimplMarker("access_notifications")

__all__ = [
# App Group Lifecycle Plugin
"app_group_lifecycle_plugin_name",
"AppGroupLifecyclePluginConfigProperty",
"AppGroupLifecyclePluginFilteringError",
"AppGroupLifecyclePluginMetadata",
"AppGroupLifecyclePluginSpec",
"AppGroupLifecyclePluginStatusProperty",
"get_app_group_lifecycle_hook",
"get_app_group_lifecycle_plugins",
"get_app_group_lifecycle_plugin_to_invoke",
"get_app_group_lifecycle_plugin_app_config_properties",
"get_app_group_lifecycle_plugin_group_config_properties",
"get_app_group_lifecycle_plugin_app_status_properties",
"get_app_group_lifecycle_plugin_group_status_properties",
"get_config_value",
"get_status_value",
"merge_app_lifecycle_plugin_data",
"set_status_value",
"validate_app_group_lifecycle_plugin_app_config",
"validate_app_group_lifecycle_plugin_group_config",
"app_group_lifecycle_hook_impl",
# Conditional Access Plugin
"ConditionalAccessResponse",
"conditional_access_hook_impl",
"get_conditional_access_hook",
"conditional_access_hook_impl",
# Notifications Plugin
"get_notification_hook",
"notification_hook_impl",
]
Loading