diff --git a/api/app.py b/api/app.py index e8962c10..9bd4736c 100644 --- a/api/app.py +++ b/api/app.py @@ -28,6 +28,7 @@ exception_views, groups_views, health_check_views, + plugins_views, role_requests_views, roles_views, tags_views, @@ -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") @@ -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 ########################################## @@ -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 diff --git a/api/config.py b/api/config.py index c6fcf4fe..6781ebbc 100644 --- a/api/config.py +++ b/api/config.py @@ -10,7 +10,7 @@ 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", "wumpus@discord.com") # Optional env var to set a custom Okta Group Profile attribute for Access management inclusion/exclusion @@ -18,7 +18,7 @@ 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") @@ -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") diff --git a/api/manage.py b/api/manage.py index e1085841..49122bef 100644 --- a/api/manage.py +++ b/api/manage.py @@ -1,3 +1,5 @@ +from typing import List + import click from flask.cli import with_appcontext @@ -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, @@ -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") diff --git a/api/models/core_models.py b/api/models/core_models.py index 626445ca..edae02d1 100644 --- a/api/models/core_models.py +++ b/api/models/core_models.py @@ -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 @@ -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( @@ -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( diff --git a/api/operations/create_group.py b/api/operations/create_group.py index c1af9fc1..8a6292a5 100644 --- a/api/operations/create_group.py +++ b/api/operations/create_group.py @@ -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 @@ -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: diff --git a/api/operations/delete_group.py b/api/operations/delete_group.py index c20d8d0c..ea8ca2ff 100644 --- a/api/operations/delete_group.py +++ b/api/operations/delete_group.py @@ -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 @@ -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) diff --git a/api/operations/modify_group_users.py b/api/operations/modify_group_users.py index 10871818..1afed46c 100644 --- a/api/operations/modify_group_users.py +++ b/api/operations/modify_group_users.py @@ -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 @@ -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 @@ -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)) diff --git a/api/plugins/__init__.py b/api/plugins/__init__.py index 2e89399b..8585fd57 100644 --- a/api/plugins/__init__.py +++ b/api/plugins/__init__.py @@ -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", ] diff --git a/api/plugins/app_group_lifecycle.py b/api/plugins/app_group_lifecycle.py new file mode 100644 index 00000000..7d3fb375 --- /dev/null +++ b/api/plugins/app_group_lifecycle.py @@ -0,0 +1,529 @@ +import logging +import sys +from dataclasses import asdict, dataclass +from typing import Any, Literal + +import pluggy +from sqlalchemy.orm import Session + +from api.models import App, AppGroup, OktaUser + +app_group_lifecycle_plugin_name = "access_app_group_lifecycle" +hookspec = pluggy.HookspecMarker(app_group_lifecycle_plugin_name) +hookimpl = pluggy.HookimplMarker(app_group_lifecycle_plugin_name) + +_cached_app_group_lifecycle_hook: pluggy.HookRelay | None = None + +logger = logging.getLogger(__name__) + + +@dataclass +class AppGroupLifecyclePluginMetadata: + """Metadata for an app group lifecycle plugin.""" + + id: str + display_name: str + description: str + + +_cached_plugin_registry: list[AppGroupLifecyclePluginMetadata] | None = None + + +@dataclass +class AppGroupLifecyclePluginConfigProperty: + """Schema for a configuration property required by an app group lifecycle plugin.""" + + display_name: str + help_text: str | None = None + type: Literal["text", "number", "boolean"] = "text" + default_value: Any = None + required: bool = False + validation: dict[str, Any] | None = None + + +@dataclass +class AppGroupLifecyclePluginStatusProperty: + """Schema for a status property exposed by an app group lifecycle plugin.""" + + display_name: str + help_text: str | None = None + type: Literal["text", "number", "date", "boolean"] = "text" + + +@dataclass +class AppGroupLifecyclePluginData: + """Data for an app group lifecycle plugin.""" + + configuration: dict[str, Any] + status: dict[str, Any] + + +class AppGroupLifecyclePluginSpec: + """Plugin specification for managing app group lifecycles.""" + + @hookspec + def get_plugin_metadata(self) -> AppGroupLifecyclePluginMetadata | None: + """Return the metadata for this plugin implementation.""" + + # Configuration hooks + + @hookspec + def get_plugin_app_config_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginConfigProperty] | None: + """ + Return the schema for app configuration plugin data, a mapping of property IDs to descriptors. + + Args: + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + @hookspec + def validate_plugin_app_config(self, config: dict[str, Any], plugin_id: str | None = None) -> dict[str, str] | None: + """ + Validate app plugin config before saving. + + Args: + config: The configuration to validate. + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + + Returns: + A dictionary mapping any invalid fields to error messages, or an empty dictionary if the configuration is valid. + """ + + @hookspec + def get_plugin_group_config_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginConfigProperty] | None: + """ + Return the schema for app group configuration plugin data, a mapping of property IDs to descriptors. + + Args: + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + @hookspec + def validate_plugin_group_config( + self, config: dict[str, Any], plugin_id: str | None = None + ) -> dict[str, str] | None: + """ + Validate app group plugin config before saving. + + Args: + config: The configuration to validate. + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + + Returns: + A dictionary mapping any invalid fields to error messages. + """ + + # Status hooks + + @hookspec + def get_plugin_app_status_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginStatusProperty] | None: + """ + Return the schema for app-level status plugin data, a mapping of property IDs to descriptors. + + Args: + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + @hookspec + def get_plugin_group_status_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginStatusProperty] | None: + """ + Return the schema for group-level status plugin data, a mapping of property IDs to descriptors. + + Args: + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + # Group lifecycle hooks + + @hookspec + def group_created(self, session: Session, group: AppGroup, plugin_id: str | None = None) -> None: + """ + Handle group creation. + + Args: + session: The Access database session. + group: The app group that was created. + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + @hookspec + def group_deleted(self, session: Session, group: AppGroup, plugin_id: str | None = None) -> None: + """ + Handle group deletion. + + Args: + session: The Access database session. + group: The app group that was deleted. + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + # Membership hooks + + @hookspec + def group_members_added( + self, session: Session, group: AppGroup, members: list[OktaUser], plugin_id: str | None = None + ) -> None: + """ + Handle member addition. + + Args: + session: The Access database session. + group: The app group to which members were added. + members: The list of users that were added to the group. + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + @hookspec + def group_members_removed( + self, session: Session, group: AppGroup, members: list[OktaUser], plugin_id: str | None = None + ) -> None: + """ + Handle member removal. + + Args: + session: The Access database session. + group: The app group from which members were removed. + members: The list of users that were removed from the group. + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + @hookspec + def sync_all_group_membership(self, session: Session, app: App, plugin_id: str | None = None) -> None: + """ + Bulk sync all group memberships for an app. This is invoked periodically by a CLI command `flask sync-app-group-memberships`. + + Args: + session: The Access database session. + app: The app for which to sync all group membership. + plugin_id: If provided, only the plugin matching this ID should respond. + If None, all plugins may respond. + """ + + +def get_app_group_lifecycle_hook() -> pluggy.HookRelay: + """Get the hook relay for app group lifecycle plugins.""" + global _cached_app_group_lifecycle_hook + + if _cached_app_group_lifecycle_hook is not None: + return _cached_app_group_lifecycle_hook + + pm = pluggy.PluginManager(app_group_lifecycle_plugin_name) + pm.add_hookspecs(AppGroupLifecyclePluginSpec) + + # Register the hook wrappers + pm.register(sys.modules[__name__]) + + count = pm.load_setuptools_entrypoints(app_group_lifecycle_plugin_name) + logger.info(f"Loaded {count} app group lifecycle plugin(s)") + + _cached_app_group_lifecycle_hook = pm.hook + + return _cached_app_group_lifecycle_hook + + +def get_app_group_lifecycle_plugins() -> list[AppGroupLifecyclePluginMetadata]: + """ + Get a registry of all loaded app group lifecycle plugins with their metadata. + + Returns: + A list of plugin metadata objects. + """ + global _cached_plugin_registry + + if _cached_plugin_registry is not None: + return _cached_plugin_registry + + hook = get_app_group_lifecycle_hook() + + # Collect metadata from all registered plugins + plugins: list[AppGroupLifecyclePluginMetadata] = [ + plugin for plugin in hook.get_plugin_metadata() if plugin is not None + ] + + # Validate uniqueness + seen_ids: set[str] = set() + seen_display_names: set[str] = set() + seen_descriptions: set[str] = set() + + for plugin in plugins: + if not plugin.id: + raise ValueError("Plugin ID is required") + if not plugin.display_name: + raise ValueError(f"Display name is required but missing for plugin {plugin.id}") + if not plugin.description: + raise ValueError(f"Description is required but missing for plugin {plugin.id}") + + if plugin.id in seen_ids: + raise ValueError(f"Duplicate plugin ID detected: {plugin.id}") + if plugin.display_name in seen_display_names: + raise ValueError(f"Duplicate plugin display name detected: {plugin.display_name}") + if plugin.description in seen_descriptions: + raise ValueError(f"Duplicate plugin description detected: {plugin.description}") + + seen_ids.add(plugin.id) + seen_display_names.add(plugin.display_name) + seen_descriptions.add(plugin.description) + + _cached_plugin_registry = plugins + logger.info(f"Registered {len(plugins)} app group lifecycle plugin(s): {[plugin.id for plugin in plugins]}") + + return _cached_plugin_registry + + +def get_app_group_lifecycle_plugin_to_invoke(group: Any) -> str | None: + """ + Determine the ID of the app group lifecycle plugin to invoke for a given group, if any. + + Args: + group: The app group for which to determine the app group lifecycle plugin to invoke. + + Returns: + The ID of the app group lifecycle plugin to invoke, or None if no plugin is configured. + """ + if type(group) is not AppGroup or group.app.app_group_lifecycle_plugin is None: + return None + + return group.app.app_group_lifecycle_plugin + + +def _get_data_for_plugin(plugin_data: dict[str, Any], plugin_id: str) -> AppGroupLifecyclePluginData: + """ + Get the data for a particular app group lifecycle plugin. + + Args: + plugin_data: The app or group's raw plugin_data property. + plugin_id: The ID of the plugin which should respond. + + Returns: + The data for the plugin. + """ + this_plugin_data = plugin_data.get(plugin_id, {}) + if not isinstance(this_plugin_data, dict): + raise ValueError(f"The data for app group lifecycle plugin '{plugin_id}' must be a dictionary") + + configuration = this_plugin_data.get("configuration", {}) + if not isinstance(configuration, dict): + raise ValueError( + f"The configuration property in the data for app group lifecycle plugin '{plugin_id}' must be a dictionary" + ) + + status = this_plugin_data.get("status", {}) + if not isinstance(status, dict): + raise ValueError( + f"The status property in the data for app group lifecycle plugin '{plugin_id}' must be a dictionary" + ) + + return AppGroupLifecyclePluginData(configuration, status) + + +def get_config_value( + app_or_group: App | AppGroup, config_property_name: str, plugin_id: str, default: Any | None = None +) -> Any: + """ + Get a configuration value for a particular app group lifecycle plugin. + Should only be called by the plugin itself. + + Args: + app_or_group: The app or group to get the configuration value for. + config_property_name: The name of the configuration property to get. + plugin_id: The ID of the plugin. + default: The default value to return if the property is not found. + + Returns: + The configuration value for the property, or the default value if the property is not found. + """ + return _get_data_for_plugin(app_or_group.plugin_data, plugin_id).configuration.get(config_property_name, default) + + +def get_status_value( + app_or_group: App | AppGroup, status_property_name: str, plugin_id: str, default: Any | None = None +) -> Any: + """ + Get a status value for a particular app group lifecycle plugin. + Should only be called by the plugin itself. + + Args: + app_or_group: The app or group to get the status value for. + status_property_name: The name of the status property to get. + plugin_id: The ID of the plugin. + default: The default value to return if the property is not found. + + Returns: + The status value for the property, or the default value if the property is not found. + """ + return _get_data_for_plugin(app_or_group.plugin_data, plugin_id).status.get(status_property_name, default) + + +def set_status_value(app_or_group: App | AppGroup, status_property_name: str, value: Any, plugin_id: str) -> None: + """ + set a status value for a particular app group lifecycle plugin. + Should only be called by the plugin itself. + + Args: + app_or_group: The app or group to set the status value for. + status_property_name: The name of the status property to set. + value: The value to set. + plugin_id: The ID of the plugin. + """ + data = _get_data_for_plugin(app_or_group.plugin_data, plugin_id) + data.status[status_property_name] = value + app_or_group.plugin_data[plugin_id] = asdict(data) + + +def merge_app_lifecycle_plugin_data(app_or_group: App | AppGroup, old_plugin_data: dict[str, Any]) -> None: + """ + Update the app lifecycle plugin data on the new app or group object by merging with the plugin data from the existing object. + + Args: + app_or_group: The existing app or group for which to update the plugin data. + old_plugin_data: The plugin data of the existing app or group object, which may be a partial patch. + """ + app_group_lifecycle_plugin_ids = [plugin.id for plugin in get_app_group_lifecycle_plugins()] + for plugin_id in old_plugin_data: + if plugin_id in app_group_lifecycle_plugin_ids: + data = _get_data_for_plugin(old_plugin_data, plugin_id) + patch_data = _get_data_for_plugin(app_or_group.plugin_data, plugin_id) + data.configuration.update(patch_data.configuration) + data.status.update(patch_data.status) + app_or_group.plugin_data[plugin_id] = asdict(obj=data) + + +class AppGroupLifecyclePluginFilteringError(Exception): + """Exception raised when no or multiple app group lifecycle plugins respond to a hook call.""" + + def __init__(self, plugin_id: str, response_count: int): + self.plugin_id = plugin_id + self.response_count = response_count + super().__init__(f"Expected one response for plugin '{plugin_id}' but got {response_count}") + + +def _get_hook_call_response(hook_caller: pluggy.HookCaller, plugin_id: str, **args: dict[str, Any]) -> Any: + """ + Get a response from a particular app group lifecycle plugin. + + Args: + hook_caller: The hook caller to use. + plugin_id: The ID of the plugin which should respond. + **args: Additional arguments to pass to the hook caller. + + Returns: + The singular plugin response. + """ + responses = [response for response in hook_caller(plugin_id=plugin_id, **args) if response is not None] + if len(responses) != 1: + raise AppGroupLifecyclePluginFilteringError(plugin_id, len(responses)) + return responses[0] + + +def get_app_group_lifecycle_plugin_app_config_properties( + plugin_id: str, +) -> dict[str, AppGroupLifecyclePluginConfigProperty]: + """ + Get the app-level configuration properties for a particular app group lifecycle plugin. + + Args: + plugin_id: The ID of the plugin which should respond. + + Returns: + A dictionary mapping configuration property names to schemas. + """ + hook = get_app_group_lifecycle_hook() + return _get_hook_call_response(hook.get_plugin_app_config_properties, plugin_id) + + +def get_app_group_lifecycle_plugin_group_config_properties( + plugin_id: str, +) -> dict[str, AppGroupLifecyclePluginConfigProperty]: + """ + Get the group-level configuration properties for a particular app group lifecycle plugin. + + Args: + plugin_id: The ID of the plugin which should respond. + + Returns: + A dictionary mapping configuration property names to schemas. + """ + hook = get_app_group_lifecycle_hook() + return _get_hook_call_response(hook.get_plugin_group_config_properties, plugin_id) + + +def validate_app_group_lifecycle_plugin_app_config(plugin_data: dict[str, Any], plugin_id: str) -> dict[str, str]: + """ + Validate the app-level configuration data for a particular app group lifecycle plugin. + + Args: + plugin_data: The plugin data to validate. + plugin_id: The ID of the plugin which should respond. + + Returns: + A dictionary mapping any invalid fields to error messages. + """ + configuration = _get_data_for_plugin(plugin_data, plugin_id).configuration + hook = get_app_group_lifecycle_hook() + return _get_hook_call_response(hook.validate_plugin_app_config, plugin_id, config=configuration) + + +def validate_app_group_lifecycle_plugin_group_config(plugin_data: dict[str, Any], plugin_id: str) -> dict[str, str]: + """ + Validate the group-level configuration data for a particular app group lifecycle plugin. + + Args: + plugin_data: The group's plugin data property. + plugin_id: The ID of the plugin which should respond. + + Returns: + A dictionary mapping any invalid fields to error messages. + """ + configuration = _get_data_for_plugin(plugin_data, plugin_id).configuration + hook = get_app_group_lifecycle_hook() + return _get_hook_call_response(hook.validate_plugin_group_config, plugin_id, config=configuration) + + +def get_app_group_lifecycle_plugin_app_status_properties( + plugin_id: str, +) -> dict[str, AppGroupLifecyclePluginStatusProperty]: + """ + Get the app-level status properties for a particular app group lifecycle plugin. + + Args: + plugin_id: The ID of the plugin which should respond. + + Returns: + A dictionary mapping status property names to schemas. + """ + hook = get_app_group_lifecycle_hook() + return _get_hook_call_response(hook.get_plugin_app_status_properties, plugin_id) + + +def get_app_group_lifecycle_plugin_group_status_properties( + plugin_id: str, +) -> dict[str, AppGroupLifecyclePluginStatusProperty]: + """ + Get the group-level status properties for a particular app group lifecycle plugin. + + Args: + plugin_id: The ID of the plugin which should respond. + + Returns: + A dictionary mapping status property names to schemas. + """ + hook = get_app_group_lifecycle_hook() + return _get_hook_call_response(hook.get_plugin_group_status_properties, plugin_id) diff --git a/api/views/plugins_views.py b/api/views/plugins_views.py new file mode 100644 index 00000000..5854e79e --- /dev/null +++ b/api/views/plugins_views.py @@ -0,0 +1,62 @@ +from flask import Blueprint + +from api.extensions import Api, docs +from api.views.resources import ( + AppGroupLifecyclePluginAppConfigProperties, + AppGroupLifecyclePluginAppStatusProperties, + AppGroupLifecyclePluginGroupConfigProperties, + AppGroupLifecyclePluginGroupStatusProperties, + AppGroupLifecyclePluginList, +) + +bp_name = "api-plugins" +bp_url_prefix = "/api/plugins" +bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) + +api = Api(bp) + +api.add_resource(AppGroupLifecyclePluginList, "/app-group-lifecycle", endpoint="app_group_lifecycle_plugins") +api.add_resource( + AppGroupLifecyclePluginAppConfigProperties, + "/app-group-lifecycle//app-config-props", + endpoint="app_group_lifecycle_plugin_app_config_props", +) +api.add_resource( + AppGroupLifecyclePluginGroupConfigProperties, + "/app-group-lifecycle//group-config-props", + endpoint="app_group_lifecycle_plugin_group_config_props", +) +api.add_resource( + AppGroupLifecyclePluginAppStatusProperties, + "/app-group-lifecycle//app-status-props", + endpoint="app_group_lifecycle_plugin_app_status_props", +) +api.add_resource( + AppGroupLifecyclePluginGroupStatusProperties, + "/app-group-lifecycle//group-status-props", + endpoint="app_group_lifecycle_plugin_group_status_props", +) + + +def register_docs() -> None: + docs.register(AppGroupLifecyclePluginList, blueprint=bp_name, endpoint="app_group_lifecycle_plugins") + docs.register( + AppGroupLifecyclePluginAppConfigProperties, + blueprint=bp_name, + endpoint="app_group_lifecycle_plugin_app_config_props", + ) + docs.register( + AppGroupLifecyclePluginGroupConfigProperties, + blueprint=bp_name, + endpoint="app_group_lifecycle_plugin_group_config_props", + ) + docs.register( + AppGroupLifecyclePluginAppStatusProperties, + blueprint=bp_name, + endpoint="app_group_lifecycle_plugin_app_status_props", + ) + docs.register( + AppGroupLifecyclePluginGroupStatusProperties, + blueprint=bp_name, + endpoint="app_group_lifecycle_plugin_group_status_props", + ) diff --git a/api/views/resources/__init__.py b/api/views/resources/__init__.py index 6620b121..c553bebd 100644 --- a/api/views/resources/__init__.py +++ b/api/views/resources/__init__.py @@ -3,6 +3,13 @@ from api.views.resources.audit import GroupRoleAuditResource, UserGroupAuditResource from api.views.resources.bug import SentryProxyResource from api.views.resources.group import GroupAuditResource, GroupList, GroupMemberResource, GroupResource +from api.views.resources.plugin import ( + AppGroupLifecyclePluginAppConfigProperties, + AppGroupLifecyclePluginAppStatusProperties, + AppGroupLifecyclePluginGroupConfigProperties, + AppGroupLifecyclePluginGroupStatusProperties, + AppGroupLifecyclePluginList, +) from api.views.resources.role import RoleAuditResource, RoleList, RoleMemberResource, RoleResource from api.views.resources.role_request import RoleRequestList, RoleRequestResource from api.views.resources.tag import TagList, TagResource @@ -12,6 +19,11 @@ __all__ = [ "AccessRequestList", "AccessRequestResource", + "AppGroupLifecyclePluginAppConfigProperties", + "AppGroupLifecyclePluginAppStatusProperties", + "AppGroupLifecyclePluginGroupConfigProperties", + "AppGroupLifecyclePluginGroupStatusProperties", + "AppGroupLifecyclePluginList", "AppList", "AppResource", "GroupAuditResource", diff --git a/api/views/resources/app.py b/api/views/resources/app.py index 8224fac0..89c3c049 100644 --- a/api/views/resources/app.py +++ b/api/views/resources/app.py @@ -11,6 +11,7 @@ from api.models.app_group import app_owners_group_description from api.operations import CreateApp, DeleteApp, ModifyAppTags from api.pagination import paginate +from api.plugins.app_group_lifecycle import merge_app_lifecycle_plugin_data from api.services import okta from api.views.schemas import ( AppPaginationSchema, @@ -94,6 +95,17 @@ def put(self, app: App) -> ResponseReturnValue: ) app_changes = schema.load(request.json) + # Enforce stricter authorization for plugin configuration changes to prevent + # privilege escalation in the managed apps. + if ( + app_changes.app_group_lifecycle_plugin != app.app_group_lifecycle_plugin + or app_changes.plugin_data != app.plugin_data + ) and not AuthorizationHelpers.is_access_admin(): + abort( + 403, + "Only Access owners are allowed to configure plugins at the app level", + ) + if app_changes.name.lower() != app.name.lower(): existing_app = ( App.query.filter(func.lower(App.name) == func.lower(app_changes.name)) @@ -131,8 +143,19 @@ def put(self, app: App) -> ResponseReturnValue: abort(400, "Only tags can be modified for the Access application") old_app_name = app.name + + old_plugin_data = app.plugin_data + app = schema.load(request.json, instance=app) + if old_plugin_data and app.plugin_data != old_plugin_data: + # Apply partial updates to the plugin data + for key in old_plugin_data: + if key not in app.plugin_data: + app.plugin_data[key] = old_plugin_data[key] + + merge_app_lifecycle_plugin_data(app, old_plugin_data) + # Update all app group names when updating app name if app.name != old_app_name: old_name_prefix = f"{AppGroup.APP_GROUP_NAME_PREFIX}{old_app_name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}" diff --git a/api/views/resources/group.py b/api/views/resources/group.py index 88182768..9092e49d 100644 --- a/api/views/resources/group.py +++ b/api/views/resources/group.py @@ -22,6 +22,7 @@ ) from api.operations.constraints import CheckForReason, CheckForSelfAdd from api.pagination import paginate +from api.plugins.app_group_lifecycle import merge_app_lifecycle_plugin_data from api.services import okta from api.views.schemas import ( AuditLogSchema, @@ -117,6 +118,17 @@ def put(self, group: OktaGroup) -> ResponseReturnValue: "Groups not managed by Access cannot be modified", ) + # Enforce stricter authorization for plugin configuration changes to prevent + # privilege escalation in the managed apps. + if group_changes.plugin_data != group.plugin_data and not ( + AuthorizationHelpers.is_access_admin() + or (type(group) is AppGroup and AuthorizationHelpers.is_app_owner_group_owner(app_group=group)) + ): + abort( + 403, + "Only Access owners and app owners are allowed to configure plugins at the group level", + ) + json_data = request.get_json() if "tags_to_remove" in json_data: if len(json_data["tags_to_remove"]) > 0 and not AuthorizationHelpers.is_access_admin(): @@ -171,8 +183,20 @@ def put(self, group: OktaGroup) -> ResponseReturnValue: group=group, group_changes=group_changes, current_user_id=g.current_user_id ).execute() + old_plugin_data = group.plugin_data + # Update additional fields like name, description, etc. group = schema.load(request.json, instance=group) + + if old_plugin_data and group.plugin_data != old_plugin_data: + # Apply partial updates to the plugin data + for key in old_plugin_data: + if key not in group.plugin_data: + group.plugin_data[key] = old_plugin_data[key] + + if type(group) is AppGroup: + merge_app_lifecycle_plugin_data(group, old_plugin_data) + okta.update_group(group.id, group.name, group.description) db.session.commit() @@ -191,7 +215,7 @@ def put(self, group: OktaGroup) -> ResponseReturnValue: .first() ) - # Audit logging gnly log if group name changed + # Audit logging, only if group name changed if old_group_name.lower() != group.name.lower(): current_app.logger.info( AuditLogSchema().dumps( diff --git a/api/views/resources/plugin.py b/api/views/resources/plugin.py new file mode 100644 index 00000000..f6a428a4 --- /dev/null +++ b/api/views/resources/plugin.py @@ -0,0 +1,127 @@ +from dataclasses import asdict + +from flask.typing import ResponseReturnValue +from flask_apispec import MethodResource + +from api.apispec import FlaskApiSpecDecorators +from api.plugins.app_group_lifecycle import ( + 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_plugins, +) +from api.views.schemas import ( + AppGroupLifecyclePluginConfigPropertySchema, + AppGroupLifecyclePluginMetadataSchema, + AppGroupLifecyclePluginStatusPropertySchema, +) + + +class AppGroupLifecyclePluginList(MethodResource): + """Resource for listing available app group lifecycle plugins.""" + + @FlaskApiSpecDecorators.response_schema(AppGroupLifecyclePluginMetadataSchema) + def get(self) -> ResponseReturnValue: + """ + Get a list of all available app group lifecycle plugins. + + Returns: + A list of plugin metadata objects. + """ + plugins = get_app_group_lifecycle_plugins() + # Sort by display name alphabetically (ascending) + plugins = sorted(plugins, key=lambda p: p.display_name.lower()) + return [asdict(plugin) for plugin in plugins], 200 + + +class AppGroupLifecyclePluginAppConfigProperties(MethodResource): + """Resource for getting app-level configuration properties for a specific plugin.""" + + @FlaskApiSpecDecorators.response_schema(AppGroupLifecyclePluginConfigPropertySchema) + def get(self, plugin_id: str) -> ResponseReturnValue: + """ + Get app-level configuration properties for a specific plugin. + + Args: + plugin_id: The ID of the plugin + + Returns: + Dictionary mapping property names to property schemas + """ + # Verify the plugin is registered + plugins = [plugin.id for plugin in get_app_group_lifecycle_plugins()] + if plugin_id not in plugins: + return {"error": f"Plugin '{plugin_id}' not found"}, 404 + + config_properties = get_app_group_lifecycle_plugin_app_config_properties(plugin_id) + return {name: asdict(schema) for name, schema in config_properties.items()}, 200 + + +class AppGroupLifecyclePluginGroupConfigProperties(MethodResource): + """Resource for getting group-level configuration properties for a specific plugin.""" + + @FlaskApiSpecDecorators.response_schema(AppGroupLifecyclePluginConfigPropertySchema) + def get(self, plugin_id: str) -> ResponseReturnValue: + """ + Get group-level configuration properties for a specific plugin. + + Args: + plugin_id: The ID of the plugin + + Returns: + Dictionary mapping property names to property schemas + """ + # Verify the plugin is registered + plugins = [plugin.id for plugin in get_app_group_lifecycle_plugins()] + if plugin_id not in plugins: + return {"error": f"Plugin '{plugin_id}' not found"}, 404 + + config_properties = get_app_group_lifecycle_plugin_group_config_properties(plugin_id) + return {name: asdict(schema) for name, schema in config_properties.items()}, 200 + + +class AppGroupLifecyclePluginAppStatusProperties(MethodResource): + """Resource for getting app-level status properties for a specific plugin.""" + + @FlaskApiSpecDecorators.response_schema(AppGroupLifecyclePluginStatusPropertySchema) + def get(self, plugin_id: str) -> ResponseReturnValue: + """ + Get app-level status properties for a specific plugin. + + Args: + plugin_id: The ID of the plugin + + Returns: + Dictionary mapping property names to property schemas + """ + # Verify the plugin is registered + plugins = [plugin.id for plugin in get_app_group_lifecycle_plugins()] + if plugin_id not in plugins: + return {"error": f"Plugin '{plugin_id}' not found"}, 404 + + status_properties = get_app_group_lifecycle_plugin_app_status_properties(plugin_id) + return {name: asdict(schema) for name, schema in status_properties.items()}, 200 + + +class AppGroupLifecyclePluginGroupStatusProperties(MethodResource): + """Resource for getting group-level status properties for a specific plugin.""" + + @FlaskApiSpecDecorators.response_schema(AppGroupLifecyclePluginStatusPropertySchema) + def get(self, plugin_id: str) -> ResponseReturnValue: + """ + Get group-level status properties for a specific plugin. + + Args: + plugin_id: The ID of the plugin + + Returns: + Dictionary mapping property names to property schemas + """ + # Verify the plugin is registered + plugins = [plugin.id for plugin in get_app_group_lifecycle_plugins()] + if plugin_id not in plugins: + return {"error": f"Plugin '{plugin_id}' not found"}, 404 + + status_properties = get_app_group_lifecycle_plugin_group_status_properties(plugin_id) + return {name: asdict(schema) for name, schema in status_properties.items()}, 200 diff --git a/api/views/schemas/__init__.py b/api/views/schemas/__init__.py index d8df67ed..43c23364 100644 --- a/api/views/schemas/__init__.py +++ b/api/views/schemas/__init__.py @@ -5,6 +5,9 @@ from api.views.schemas.audit_logs import AuditLogSchema, EventType from api.views.schemas.core_schemas import ( AccessRequestSchema, + AppGroupLifecyclePluginConfigPropertySchema, + AppGroupLifecyclePluginMetadataSchema, + AppGroupLifecyclePluginStatusPropertySchema, AppGroupSchema, AppSchema, AppTagMapSchema, @@ -47,6 +50,9 @@ __all__ = [ "AccessRequestPaginationSchema", "AccessRequestSchema", + "AppGroupLifecyclePluginConfigPropertySchema", + "AppGroupLifecyclePluginMetadataSchema", + "AppGroupLifecyclePluginStatusPropertySchema", "AppGroupSchema", "AppPaginationSchema", "AppSchema", diff --git a/api/views/schemas/core_schemas.py b/api/views/schemas/core_schemas.py index 35d32e09..ba9dafbf 100644 --- a/api/views/schemas/core_schemas.py +++ b/api/views/schemas/core_schemas.py @@ -5,6 +5,7 @@ from marshmallow.schema import SchemaMeta, SchemaOpts from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field from sqlalchemy.orm import Session + from api.access_config import get_access_config from api.extensions import db from api.models import ( @@ -21,6 +22,10 @@ RoleRequest, Tag, ) +from api.plugins.app_group_lifecycle import ( + validate_app_group_lifecycle_plugin_app_config, + validate_app_group_lifecycle_plugin_group_config, +) access_config = get_access_config() @@ -850,6 +855,7 @@ class AppGroupSchema(SQLAlchemyAutoSchema): description = auto_field(load_default="", validate=validate.Length(max=1024)) externally_managed_data = fields.Dict() + plugin_data = fields.Dict() @validates_schema def validate_app_group(self, data: Dict[str, Any], **kwargs: Any) -> None: @@ -865,8 +871,38 @@ def validate_app_group(self, data: Dict[str, Any], **kwargs: Any) -> None: ) ) + @validates_schema + def validate_plugin_data(self, data: Dict[str, Any], **kwargs: Any) -> None: + # Only validate plugin_data if it's present in the data + plugin_data = data.get("plugin_data") + if plugin_data is None: + return + + # Check if plugin_data contains data for individual plugins + if not isinstance(plugin_data, dict): + raise ValidationError( + "Plugin data must be a dictionary, mapping plugin IDs to plugin-specific data", field_name="plugin_data" + ) + + # Get the app to check if it has a plugin configured + app = None + if "app_id" in data: + app = App.query.filter(App.id == data["app_id"]).filter(App.deleted_at.is_(None)).first() + + # Validate the app group lifecycle plugin configuration, if present + if app and app.app_group_lifecycle_plugin is not None: + error_message = ( + f"Configuration validation for app group lifecycle plugin '{app.app_group_lifecycle_plugin}' failed" + ) + try: + errors = validate_app_group_lifecycle_plugin_group_config(plugin_data, app.app_group_lifecycle_plugin) + except ValueError as e: + raise ValidationError(f"{error_message}: {e}", field_name="plugin_data") from e + if errors: + raise ValidationError(f"{error_message}: {errors}", field_name="plugin_data") + app_id = auto_field(required=True, validate=validate.Length(equal=20)) - app = fields.Nested(lambda: AppSchema(only=("id", "name", "deleted_at"))) + app = fields.Nested(lambda: AppSchema(only=("id", "name", "deleted_at", "app_group_lifecycle_plugin"))) tags_to_add = fields.List(fields.String(validate=validate.Length(equal=20)), load_only=True) tags_to_remove = fields.List(fields.String(validate=validate.Length(equal=20)), load_only=True) @@ -1078,6 +1114,7 @@ class Meta: "description", "is_managed", "externally_managed_data", + "plugin_data", "is_owner", "app", "app_id", @@ -1153,6 +1190,57 @@ class AppSchema(SQLAlchemyAutoSchema): ), ) description = auto_field(validate=validate.Length(max=1024)) + app_group_lifecycle_plugin = auto_field(validate=validate.Length(max=255), allow_none=True) + plugin_data = fields.Dict() + + @validates_schema + def validate_app_group_lifecycle_plugin(self, data: Dict[str, Any], **kwargs: Any) -> None: + # Only validate if app_group_lifecycle_plugin is present and not None + plugin_id = data.get("app_group_lifecycle_plugin") + if plugin_id is None: + return + + # Basic validation: ensure it's a non-empty string + if not isinstance(plugin_id, str) or not plugin_id.strip(): + raise ValidationError( + "App group lifecycle plugin ID must be a non-empty string", field_name="app_group_lifecycle_plugin" + ) + + # Validate that plugin_id corresponds to a registered app group lifecycle plugin + from api.plugins.app_group_lifecycle import get_app_group_lifecycle_plugins + + registered_plugins = [plugin.id for plugin in get_app_group_lifecycle_plugins()] + if plugin_id not in registered_plugins: + raise ValidationError( + f"Invalid app group lifecycle plugin ID: '{plugin_id}' is not a registered plugin. Available plugins: {', '.join(registered_plugins)}", + field_name="app_group_lifecycle_plugin", + ) + + @validates_schema + def validate_plugin_data(self, data: Dict[str, Any], **kwargs: Any) -> None: + # Only validate plugin_data if it's present in the data + plugin_data = data.get("plugin_data") + if plugin_data is None: + return + + # Check if plugin_data is a dictionary + if not isinstance(plugin_data, dict): + raise ValidationError( + "Plugin data must be a dictionary, mapping plugin IDs to plugin-specific data", field_name="plugin_data" + ) + + # Validate app group lifecycle plugin configuration, if present + app_group_lifecycle_plugin = data.get("app_group_lifecycle_plugin") + if app_group_lifecycle_plugin is not None: + error_message = ( + f"Configuration validation for app group lifecycle plugin '{app_group_lifecycle_plugin}' failed" + ) + try: + errors = validate_app_group_lifecycle_plugin_app_config(plugin_data, app_group_lifecycle_plugin) + except ValueError as e: + raise ValidationError(f"{error_message}: {e}", field_name="plugin_data") from e + if errors: + raise ValidationError(f"{error_message}: {errors}", field_name="plugin_data") initial_owner_id = fields.String(validate=validate.Length(min=1, max=255), load_only=True) initial_owner_role_ids = fields.List(fields.String(validate=validate.Length(equal=20)), load_only=True) @@ -1201,6 +1289,8 @@ class Meta: "deleted_at", "name", "description", + "app_group_lifecycle_plugin", + "plugin_data", "initial_owner_id", "initial_owner_role_ids", "initial_additional_app_groups", @@ -1678,3 +1768,25 @@ class Meta: "group_tag_mapping", "active_group_tag_mappings", ) + + +# Plugin-related schemas +class AppGroupLifecyclePluginMetadataSchema(Schema): + id = fields.String(required=True) + display_name = fields.String(required=True) + description = fields.String(required=False, allow_none=True) + + +class AppGroupLifecyclePluginConfigPropertySchema(Schema): + display_name = fields.String(required=True) + help_text = fields.String(required=False, allow_none=True) + type = fields.String(required=True, validate=validate.OneOf(["text", "number", "boolean"])) + default_value = fields.Raw(required=False, allow_none=True) + required = fields.Boolean(required=False, load_default=False) + validation = fields.Dict(required=False, allow_none=True) + + +class AppGroupLifecyclePluginStatusPropertySchema(Schema): + display_name = fields.String(required=True) + help_text = fields.String(required=False, allow_none=True) + type = fields.String(required=True, validate=validate.OneOf(["text", "number", "date", "boolean"])) diff --git a/examples/plugins/app_group_lifecycle_audit_logger/README.md b/examples/plugins/app_group_lifecycle_audit_logger/README.md new file mode 100644 index 00000000..d27399fc --- /dev/null +++ b/examples/plugins/app_group_lifecycle_audit_logger/README.md @@ -0,0 +1,137 @@ +# App Group Lifecycle Audit Logger Plugin + +This is an example plugin that demonstrates how to implement an **App Group Lifecycle Plugin** for Access. The plugin logs all group lifecycle events (creation, deletion, and membership changes) to the application logs, providing a simple audit trail. + +## Overview + +This plugin serves as a reference implementation demonstrating: + +- How to implement all required hooks from `AppGroupLifecyclePluginSpec` +- How to define configuration properties for apps and groups +- How to define status properties for monitoring +- How to validate configuration data +- How to handle group lifecycle events + +The plugin doesn't integrate with any external systems—it simply logs events, making it easy to understand and test. + +## Plugin Features + +### Configuration + +**App-Level Configuration:** +- `enabled` (boolean): Enable/disable audit logging for the app +- `log_level` (text): Log level to use (INFO, WARNING, ERROR) + +**Group-Level Configuration:** +- `enabled` (boolean): Enable/disable audit logging for the group +- `custom_tag` (text): Custom tag to include in log messages + +### Status + +**App-Level Status:** +- `total_events_logged` (number): Total events logged for this app +- `last_sync_at` (date): When the last sync occurred + +**Group-Level Status:** +- `events_logged` (number): Events logged for this group +- `last_event_at` (date): When the last event occurred + +## Installation + +To install the plugin, add these lines to the App container Dockerfile: + +```dockerfile +# Install the audit logger plugin +WORKDIR /app/plugins +ADD ./examples/plugins/app_group_lifecycle_audit_logger ./app_group_lifecycle_audit_logger +RUN pip install ./app_group_lifecycle_audit_logger + +# Reset working directory +WORKDIR /app +``` + +Alternatively, for local development, install it with pip: + +```bash +pip install -e examples/plugins/app_group_lifecycle_audit_logger +``` + +## Usage + +After installation, the plugin will be automatically discovered and registered. You can configure it through the Access UI, or the API as shown below. + +### 1. List Available Plugins + +```bash +curl http://localhost:6060/api/plugins/app-group-lifecycle +``` + +### 2. Configure an App + +```bash +curl -X PUT http://localhost:6060/api/apps/{app_id} \ + -H "Content-Type: application/json" \ + -d '{ + "app_group_lifecycle_plugin": "audit_logger", + "plugin_data": { + "audit_logger": { + "configuration": { + "enabled": true, + "log_level": "INFO" + } + } + } + }' +``` + +### 3. Configure a Group + +```bash +curl -X PUT http://localhost:6060/api/groups/{group_id} \ + -H "Content-Type: application/json" \ + -d '{ + "plugin_data": { + "audit_logger": { + "configuration": { + "enabled": true, + "custom_tag": "production" + } + } + } + }' +``` + +## Files + +- **[`__init__.py`](./__init__.py)**: Plugin package initialization +- **[`plugin.py`](./plugin.py)**: Plugin implementation with all hook implementations +- **[`setup.py`](./setup.py)**: Setup script defining the plugin metadata and entry points + +## Extending This Example + +You can use this plugin as a template for creating your own app group lifecycle plugins. To create a custom plugin: + +1. Copy this directory structure +2. Replace the logging logic with your integration (e.g., Discord API, GitHub API, Google Groups API) +3. Update the plugin ID, display name, and description +4. Add any required dependencies to `setup.py` +5. Implement the configuration and status properties your integration needs +6. Update the validation logic for your specific requirements + +## Testing + +To test the plugin locally: + +1. Install the plugin in development mode: `pip install -e examples/plugins/app_group_lifecycle_audit_logger` +2. Start the Access application +3. Create/modify/delete groups and observe the logs + +You should see log messages like: + +``` +[AUDIT_LOGGER] Group created: App-MyApp-Engineers (plugin enabled) +[AUDIT_LOGGER] Members added to App-MyApp-Engineers: user1@example.com, user2@example.com +[AUDIT_LOGGER] Group deleted: App-MyApp-Engineers +``` + +You'll likely need to ensure that your environment has `SQLALCHEMY_ECHO=false`, or the audit logs may be drowned out by echoed SQL queries. diff --git a/examples/plugins/app_group_lifecycle_audit_logger/__init__.py b/examples/plugins/app_group_lifecycle_audit_logger/__init__.py new file mode 100644 index 00000000..9b7d1f1c --- /dev/null +++ b/examples/plugins/app_group_lifecycle_audit_logger/__init__.py @@ -0,0 +1,7 @@ +""" +App Group Lifecycle Audit Logger Plugin + +Example plugin that demonstrates how to implement an app group lifecycle plugin. +""" + +__version__ = "0.1.0" diff --git a/examples/plugins/app_group_lifecycle_audit_logger/plugin.py b/examples/plugins/app_group_lifecycle_audit_logger/plugin.py new file mode 100644 index 00000000..813f2c9f --- /dev/null +++ b/examples/plugins/app_group_lifecycle_audit_logger/plugin.py @@ -0,0 +1,313 @@ +""" +App Group Lifecycle Audit Logger Plugin + +This plugin demonstrates how to implement an app group lifecycle plugin. +It logs all group lifecycle events to provide a simple audit trail. +""" + +import logging +from datetime import datetime +from typing import Any + +from sqlalchemy.orm import Session + +# Import models +from api.models import App, AppGroup, OktaUser + +# Import the plugin spec and decorators +from api.plugins.app_group_lifecycle import ( + AppGroupLifecyclePluginConfigProperty, + AppGroupLifecyclePluginMetadata, + AppGroupLifecyclePluginStatusProperty, + get_config_value, + get_status_value, + hookimpl, + set_status_value, +) + +# Plugin configuration +PLUGIN_ID = "audit_logger" +PLUGIN_DISPLAY_NAME = "Audit Logger" +PLUGIN_DESCRIPTION = "Logs all group lifecycle events for auditing purposes" + +logger = logging.getLogger(__name__) + + +class AuditLoggerPlugin: + """Example plugin that logs all group lifecycle events.""" + + @hookimpl + def get_plugin_metadata(self) -> AppGroupLifecyclePluginMetadata | None: + """Return metadata for this plugin.""" + return AppGroupLifecyclePluginMetadata( + id=PLUGIN_ID, + display_name=PLUGIN_DISPLAY_NAME, + description=PLUGIN_DESCRIPTION, + ) + + # Configuration hooks + + @hookimpl + def get_plugin_app_config_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginConfigProperty] | None: + """Return app-level configuration schema.""" + # Only respond if this plugin is being queried + if plugin_id is not None and plugin_id != PLUGIN_ID: + return None + + return { + "enabled": AppGroupLifecyclePluginConfigProperty( + display_name="Enable audit logging?", + help_text="Enable or disable audit logging for all groups associated with this app", + type="boolean", + default_value=True, + required=True, + ), + "log_level": AppGroupLifecyclePluginConfigProperty( + display_name="Log Level", + help_text="The log level to use (INFO, WARNING, ERROR)", + type="text", + default_value="INFO", + required=False, + validation={ + "allowed_values": ["INFO", "WARNING", "ERROR"], + }, + ), + } + + @hookimpl + def validate_plugin_app_config(self, config: dict[str, Any], plugin_id: str | None = None) -> dict[str, str] | None: + """Validate app-level configuration.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return None + + errors = {} + + # Validate enabled field + if "enabled" not in config: + errors["enabled"] = "The 'enabled' field is required" + elif not isinstance(config["enabled"], bool): + errors["enabled"] = "The 'enabled' field must be a boolean" + + # Validate log_level if provided + if "log_level" in config: + if not isinstance(config["log_level"], str): + errors["log_level"] = "The 'log_level' field must be a string" + elif config["log_level"] not in ["INFO", "WARNING", "ERROR"]: + errors["log_level"] = "The 'log_level' must be one of: INFO, WARNING, ERROR" + + return errors if errors else {} + + @hookimpl + def get_plugin_group_config_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginConfigProperty] | None: + """Return group-level configuration schema.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return None + + return { + "enabled": AppGroupLifecyclePluginConfigProperty( + display_name="Enable audit logging?", + help_text="Enable or disable audit logging for this specific group", + type="boolean", + default_value=True, + required=True, + ), + "custom_tag": AppGroupLifecyclePluginConfigProperty( + display_name="Custom Tag", + help_text="Custom tag to include in log messages for this group", + type="text", + default_value="", + required=False, + ), + } + + @hookimpl + def validate_plugin_group_config( + self, config: dict[str, Any], plugin_id: str | None = None + ) -> dict[str, str] | None: + """Validate group-level configuration.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return None + + errors = {} + + # Validate enabled field + if "enabled" not in config: + errors["enabled"] = "The 'enabled' field is required" + elif not isinstance(config["enabled"], bool): + errors["enabled"] = "The 'enabled' field must be a boolean" + + # Validate custom_tag if provided + if "custom_tag" in config and not isinstance(config["custom_tag"], str): + errors["custom_tag"] = "The 'custom_tag' field must be a string" + + return errors if errors else {} + + # Status hooks + + @hookimpl + def get_plugin_app_status_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginStatusProperty] | None: + """Return app-level status schema.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return None + + return { + "total_events_logged": AppGroupLifecyclePluginStatusProperty( + display_name="Total Events Logged", + help_text="Total number of events logged for this app", + type="number", + ), + "last_sync_at": AppGroupLifecyclePluginStatusProperty( + display_name="Last Synced", + help_text="Timestamp of the last periodic sync of all group memberships for this app", + type="date", + ), + } + + @hookimpl + def get_plugin_group_status_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginStatusProperty] | None: + """Return group-level status schema.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return None + + return { + "events_logged": AppGroupLifecyclePluginStatusProperty( + display_name="Events Logged", + help_text="Number of events logged for this group", + type="number", + ), + "last_event_at": AppGroupLifecyclePluginStatusProperty( + display_name="Last Event Logged", + help_text="Timestamp of the last event logged for this group", + type="date", + ), + } + + # Lifecycle hooks + + @hookimpl + def group_created(self, session: Session, group: AppGroup, plugin_id: str | None = None) -> None: + """Handle group creation.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return + + if not self._is_enabled(group): + return + + self._log(f"Group created: {group.name} (app: {group.app.name})", group=group) + + # Update status + self._increment_event_count(session, group) + + @hookimpl + def group_deleted(self, session: Session, group: AppGroup, plugin_id: str | None = None) -> None: + """Handle group deletion.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return + + if not self._is_enabled(group): + return + + self._log(f"Group deleted: {group.name} (app: {group.app.name})", group=group) + + # Update status + self._increment_event_count(session, group) + + @hookimpl + def group_members_added( + self, + session: Session, + group: AppGroup, + members: list[OktaUser], + plugin_id: str | None = None, + ) -> None: + """Handle member addition.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return + + if not self._is_enabled(group): + return + + member_emails = [m.email for m in members] + self._log(f"Members added to {group.name}: {', '.join(member_emails)}", group=group) + + # Update status + self._increment_event_count(session, group) + + @hookimpl + def group_members_removed( + self, + session: Session, + group: AppGroup, + members: list[OktaUser], + plugin_id: str | None = None, + ) -> None: + """Handle member removal.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return + + if not self._is_enabled(group): + return + + member_emails = [m.email for m in members] + self._log(f"Members removed from {group.name}: {', '.join(member_emails)}", group=group) + + # Update status + self._increment_event_count(session, group) + + @hookimpl + def sync_all_group_membership(self, session: Session, app: App, plugin_id: str | None = None) -> None: + """Perform periodic sync of all group memberships.""" + if plugin_id is not None and plugin_id != PLUGIN_ID: + return + + self._log(f"Periodic sync triggered for app: {app.name}", app) + + # Update app-level status + set_status_value(app, "last_sync_at", datetime.utcnow().isoformat(), PLUGIN_ID) + session.add(app) + + # Helper methods + + def _log(self, message: str, app: App | None = None, group: AppGroup | None = None) -> None: + """Log a message at the level specified in the app's plugin configuration.""" + if app is None: + if group is None: + raise ValueError("Either app or group must be provided") + else: + app = group.app + + level_str: str = get_config_value(app, "log_level", PLUGIN_ID, "INFO") + level: int = getattr(logging, level_str) + custom_tag = get_config_value(group, "custom_tag", PLUGIN_ID) if group else "" + logger.log(level, f"[AUDIT_LOGGER]{f'[{custom_tag}]' if custom_tag else ''} {message}") + + def _is_enabled(self, group: AppGroup) -> bool: + """Check if the plugin is enabled for this group.""" + return get_config_value(group.app, "enabled", PLUGIN_ID, True) and get_config_value( + group, "enabled", PLUGIN_ID, True + ) + + def _increment_event_count(self, session: Session, group: AppGroup) -> None: + """Increment the event count in the status.""" + # Update group-level status + current_count = get_status_value(group, "events_logged", PLUGIN_ID) or 0 + set_status_value(group, "events_logged", current_count + 1, PLUGIN_ID) + set_status_value(group, "last_event_at", datetime.utcnow().isoformat(), PLUGIN_ID) + session.add(group) + + # Update app-level status + app_count = get_status_value(group.app, "total_events_logged", PLUGIN_ID) or 0 + set_status_value(group.app, "total_events_logged", app_count + 1, PLUGIN_ID) + session.add(group.app) + + +# Create plugin instance +audit_logger_plugin = AuditLoggerPlugin() diff --git a/examples/plugins/app_group_lifecycle_audit_logger/setup.py b/examples/plugins/app_group_lifecycle_audit_logger/setup.py new file mode 100644 index 00000000..2eb23a2a --- /dev/null +++ b/examples/plugins/app_group_lifecycle_audit_logger/setup.py @@ -0,0 +1,26 @@ +""" +Setup script for the App Group Lifecycle Audit Logger Plugin. + +This registers the plugin with Access via the setuptools entry_points mechanism. +""" + +from setuptools import setup + +setup( + name="app_group_lifecycle_audit_logger", + version="0.1.0", + description="Example app group lifecycle plugin that logs all group events", + author="Discord", + packages=["app_group_lifecycle_audit_logger"], + package_dir={"app_group_lifecycle_audit_logger": "."}, + install_requires=[ + "Flask", + "SQLAlchemy", + ], + # Register the plugin with the app group lifecycle plugin system + entry_points={ + "access_app_group_lifecycle": [ + "audit_logger=app_group_lifecycle_audit_logger.plugin:audit_logger_plugin", + ], + }, +) diff --git a/migrations/versions/0ed12d651875_add_app_group_lifecycle_plugin_to_app.py b/migrations/versions/0ed12d651875_add_app_group_lifecycle_plugin_to_app.py new file mode 100644 index 00000000..a3bacccd --- /dev/null +++ b/migrations/versions/0ed12d651875_add_app_group_lifecycle_plugin_to_app.py @@ -0,0 +1,27 @@ +"""add app_group_lifecycle_plugin to app + +Revision ID: 0ed12d651875 +Revises: cbc5bb2f05b7 +Create Date: 2025-10-24 21:03:01.388376 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0ed12d651875" +down_revision = "cbc5bb2f05b7" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("app", schema=None) as batch_op: + batch_op.add_column(sa.Column("app_group_lifecycle_plugin", sa.Unicode(length=255), nullable=True)) + batch_op.add_column(sa.Column("plugin_data", sa.JSON(), nullable=False, server_default="{}")) + + +def downgrade(): + with op.batch_alter_table("app", schema=None) as batch_op: + batch_op.drop_column("plugin_data") + batch_op.drop_column("app_group_lifecycle_plugin") diff --git a/src/api/apiComponents.ts b/src/api/apiComponents.ts index 3e6e4753..9c97f53b 100644 --- a/src/api/apiComponents.ts +++ b/src/api/apiComponents.ts @@ -1223,6 +1223,188 @@ export const useGetUserById = ( }); }; +/** + * Plugin API types and hooks + */ + +export type PluginsError = Fetcher.ErrorWrapper<{ + status: ClientErrorStatus | ServerErrorStatus; + payload: string; +}>; + +export type GetPluginsVariables = ApiContext['fetcherOptions']; + +export type GetPluginInfoPathParams = { + pluginId: string; +}; + +export type GetPluginInfoVariables = { + pathParams: GetPluginInfoPathParams; +} & ApiContext['fetcherOptions']; + +export const fetchGetAppGroupLifecyclePlugins = (variables: GetPluginsVariables, signal?: AbortSignal) => + apiFetch({ + url: '/api/plugins/app-group-lifecycle', + method: 'get', + ...variables, + signal, + }); + +export const useGetAppGroupLifecyclePlugins = ( + options?: Omit< + reactQuery.UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) => { + const {fetcherOptions, queryOptions, queryKeyFn} = useApiContext(options); + return reactQuery.useQuery({ + queryKey: queryKeyFn({ + path: '/api/plugins/app-group-lifecycle', + operationId: 'getAppGroupLifecyclePlugins', + variables: {}, + }), + queryFn: ({signal}) => fetchGetAppGroupLifecyclePlugins({...fetcherOptions}, signal), + ...options, + ...queryOptions, + }); +}; + +export const fetchGetAppGroupLifecyclePluginAppConfigProperties = ( + variables: GetPluginInfoVariables, + signal?: AbortSignal, +) => + apiFetch({ + url: '/api/plugins/app-group-lifecycle/{pluginId}/app-config-props', + method: 'get', + ...variables, + signal, + }); + +export const useGetAppGroupLifecyclePluginAppConfigProperties = < + TData = Schemas.AppGroupLifecyclePluginConfigProperties, +>( + variables: GetPluginInfoVariables, + options?: Omit< + reactQuery.UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) => { + const {fetcherOptions, queryOptions, queryKeyFn} = useApiContext(options); + return reactQuery.useQuery({ + queryKey: queryKeyFn({ + path: '/api/plugins/app-group-lifecycle/{pluginId}/app-config-props', + operationId: 'getAppGroupLifecyclePluginAppConfigProperties', + variables, + }), + queryFn: ({signal}) => + fetchGetAppGroupLifecyclePluginAppConfigProperties({...fetcherOptions, ...variables}, signal), + ...options, + ...queryOptions, + }); +}; + +export const fetchGetAppGroupLifecyclePluginGroupConfigProperties = ( + variables: GetPluginInfoVariables, + signal?: AbortSignal, +) => + apiFetch({ + url: '/api/plugins/app-group-lifecycle/{pluginId}/group-config-props', + method: 'get', + ...variables, + signal, + }); + +export const useGetAppGroupLifecyclePluginGroupConfigProperties = < + TData = Schemas.AppGroupLifecyclePluginConfigProperties, +>( + variables: GetPluginInfoVariables, + options?: Omit< + reactQuery.UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) => { + const {fetcherOptions, queryOptions, queryKeyFn} = useApiContext(options); + return reactQuery.useQuery({ + queryKey: queryKeyFn({ + path: '/api/plugins/app-group-lifecycle/{pluginId}/group-config-props', + operationId: 'getAppGroupLifecyclePluginGroupConfigProperties', + variables, + }), + queryFn: ({signal}) => + fetchGetAppGroupLifecyclePluginGroupConfigProperties({...fetcherOptions, ...variables}, signal), + ...options, + ...queryOptions, + }); +}; + +export const fetchGetAppGroupLifecyclePluginAppStatusProperties = ( + variables: GetPluginInfoVariables, + signal?: AbortSignal, +) => + apiFetch({ + url: '/api/plugins/app-group-lifecycle/{pluginId}/app-status-props', + method: 'get', + ...variables, + signal, + }); + +export const useGetAppGroupLifecyclePluginAppStatusProperties = < + TData = Schemas.AppGroupLifecyclePluginStatusProperties, +>( + variables: GetPluginInfoVariables, + options?: Omit< + reactQuery.UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) => { + const {fetcherOptions, queryOptions, queryKeyFn} = useApiContext(options); + return reactQuery.useQuery({ + queryKey: queryKeyFn({ + path: '/api/plugins/app-group-lifecycle/{pluginId}/app-status-props', + operationId: 'getAppGroupLifecyclePluginAppStatusProperties', + variables, + }), + queryFn: ({signal}) => + fetchGetAppGroupLifecyclePluginAppStatusProperties({...fetcherOptions, ...variables}, signal), + ...options, + ...queryOptions, + }); +}; + +export const fetchGetAppGroupLifecyclePluginGroupStatusProperties = ( + variables: GetPluginInfoVariables, + signal?: AbortSignal, +) => + apiFetch({ + url: '/api/plugins/app-group-lifecycle/{pluginId}/group-status-props', + method: 'get', + ...variables, + signal, + }); + +export const useGetAppGroupLifecyclePluginGroupStatusProperties = < + TData = Schemas.AppGroupLifecyclePluginStatusProperties, +>( + variables: GetPluginInfoVariables, + options?: Omit< + reactQuery.UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) => { + const {fetcherOptions, queryOptions, queryKeyFn} = useApiContext(options); + return reactQuery.useQuery({ + queryKey: queryKeyFn({ + path: '/api/plugins/app-group-lifecycle/{pluginId}/group-status-props', + operationId: 'getAppGroupLifecyclePluginGroupStatusProperties', + variables, + }), + queryFn: ({signal}) => + fetchGetAppGroupLifecyclePluginGroupStatusProperties({...fetcherOptions, ...variables}, signal), + ...options, + ...queryOptions, + }); +}; + export type QueryOperation = | { path: '/api/apps'; @@ -1313,4 +1495,29 @@ export type QueryOperation = path: '/api/users/{userId}'; operationId: 'getUserById'; variables: GetUserByIdVariables; + } + | { + path: '/api/plugins/app-group-lifecycle'; + operationId: 'getAppGroupLifecyclePlugins'; + variables: GetPluginsVariables; + } + | { + path: '/api/plugins/app-group-lifecycle/{pluginId}/app-config-props'; + operationId: 'getAppGroupLifecyclePluginAppConfigProperties'; + variables: GetPluginInfoVariables; + } + | { + path: '/api/plugins/app-group-lifecycle/{pluginId}/group-config-props'; + operationId: 'getAppGroupLifecyclePluginGroupConfigProperties'; + variables: GetPluginInfoVariables; + } + | { + path: '/api/plugins/app-group-lifecycle/{pluginId}/app-status-props'; + operationId: 'getAppGroupLifecyclePluginAppStatusProperties'; + variables: GetPluginInfoVariables; + } + | { + path: '/api/plugins/app-group-lifecycle/{pluginId}/group-status-props'; + operationId: 'getAppGroupLifecyclePluginGroupStatusProperties'; + variables: GetPluginInfoVariables; }; diff --git a/src/api/apiSchemas.ts b/src/api/apiSchemas.ts index 13c5e4dd..a9e85839 100644 --- a/src/api/apiSchemas.ts +++ b/src/api/apiSchemas.ts @@ -104,6 +104,8 @@ export type App = { * @format date-time */ updated_at?: string; + app_group_lifecycle_plugin?: string | null; + plugin_data?: Record; }; export type AppGroup = OktaGroup & { @@ -129,6 +131,7 @@ export type AppGroup = OktaGroup & { description?: string; tags_to_add?: string[]; tags_to_remove?: string[]; + plugin_data?: Record; }; export type AppPagination = { @@ -612,3 +615,32 @@ export type UserPagination = { results?: OktaUser[]; total?: number; }; + +export type AppGroupLifecyclePluginMetadata = { + id: string; + display_name: string; + description: string; +}; + +export type AppGroupLifecyclePluginConfigProperty = { + display_name: string; + help_text?: string; + type: 'text' | 'number' | 'boolean'; + default_value?: any; + required?: boolean; + validation?: Record; +}; + +export type AppGroupLifecyclePluginStatusProperty = { + display_name: string; + help_text?: string; + type: 'text' | 'number' | 'date' | 'boolean'; +}; + +export type AppGroupLifecyclePluginConfigProperties = { + [propertyId: string]: AppGroupLifecyclePluginConfigProperty; +}; + +export type AppGroupLifecyclePluginStatusProperties = { + [propertyId: string]: AppGroupLifecyclePluginStatusProperty; +}; diff --git a/src/components/AppGroupLifecyclePluginConfigurationForm.tsx b/src/components/AppGroupLifecyclePluginConfigurationForm.tsx new file mode 100644 index 00000000..b6667bfe --- /dev/null +++ b/src/components/AppGroupLifecyclePluginConfigurationForm.tsx @@ -0,0 +1,217 @@ +/** + * Form component for configuring App Group Lifecycle Plugins. + * Used in CreateUpdate dialogs (edit mode). + */ + +import * as React from 'react'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import MenuItem from '@mui/material/MenuItem'; +import Select, {SelectChangeEvent} from '@mui/material/Select'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import {useFormContext, Controller} from 'react-hook-form'; + +import { + useGetAppGroupLifecyclePlugins, + useGetAppGroupLifecyclePluginAppConfigProperties, + useGetAppGroupLifecyclePluginGroupConfigProperties, + PluginConfigProperty, +} from '../api/apiComponents'; + +type PluginConfiguration = { + [propertyId: string]: any; +}; + +interface AppGroupLifecyclePluginConfigurationFormProps { + /** + * Entity type at which the plugin is configured ('app' or 'group') + */ + entityType: 'app' | 'group'; + + /** + * Currently selected plugin ID (if any) + */ + selectedPluginId?: string | null; + + /** + * Current configuration values + */ + currentConfig?: PluginConfiguration; + + /** + * Callback when plugin selection changes (app level only) + */ + onPluginChange?: (pluginId: string | null) => void; +} + +/** + * Renders a single configuration field based on its schema + */ +function ConfigField({property, value, fieldName}: {property: PluginConfigProperty; value: any; fieldName: string}) { + const {register, control} = useFormContext(); + + switch (property.type) { + case 'boolean': + return ( + + ( + } label={property.display_name} /> + )} + /> + {property.help_text && {property.help_text}} + + ); + + case 'number': + return ( + + ); + + case 'text': + default: + return ( + + ); + } +} + +export default function AppGroupLifecyclePluginConfigurationForm({ + entityType, + selectedPluginId, + currentConfig = {}, + onPluginChange, +}: AppGroupLifecyclePluginConfigurationFormProps) { + const {data: plugins, isLoading: pluginsLoading} = useGetAppGroupLifecyclePlugins(); + + const useConfigPropertiesHook = + entityType === 'app' + ? useGetAppGroupLifecyclePluginAppConfigProperties + : useGetAppGroupLifecyclePluginGroupConfigProperties; + + const {data: configProperties, isLoading: configLoading} = useConfigPropertiesHook( + {pathParams: {pluginId: selectedPluginId || ''}}, + {enabled: !!selectedPluginId}, + ); + + const selectedPlugin = React.useMemo(() => { + if (!plugins || !selectedPluginId) return null; + return plugins.find((p) => p.id === selectedPluginId) || null; + }, [plugins, selectedPluginId]); + + const handlePluginSelectionChange = (event: SelectChangeEvent) => { + const newPluginId = event.target.value; + onPluginChange?.(newPluginId || null); + }; + + if (pluginsLoading) { + return ( + + + + ); + } + + if (!plugins || plugins.length === 0) { + return null; + } + + return ( + + + Configure {entityType === 'app' ? 'an' : 'the'} App Group Lifecycle Plugin + + + {/* Plugin Selection (app-level only) */} + {entityType === 'app' && ( + + + {selectedPlugin && {selectedPlugin.description}} + + )} + + {/* Display Selected Plugin (group-level only) */} + {entityType === 'group' && selectedPlugin && ( + <> + + Selected Plugin + + + + {selectedPlugin.display_name}: {selectedPlugin.description} + + + + )} + + {/* Plugin Configuration */} + {selectedPluginId && ( + <> + {configLoading ? ( + + + + ) : configProperties && Object.keys(configProperties).length > 0 ? ( + <> + + Configuration + + + {Object.entries(configProperties).map(([propertyId, property]) => { + const fieldName = `plugin_data.${selectedPluginId}.configuration.${propertyId}`; + return ( + + ); + })} + + + ) : null} + + )} + + ); +} diff --git a/src/components/AppGroupLifecyclePluginData.tsx b/src/components/AppGroupLifecyclePluginData.tsx new file mode 100644 index 00000000..a0866732 --- /dev/null +++ b/src/components/AppGroupLifecyclePluginData.tsx @@ -0,0 +1,229 @@ +/** + * Read-only component for displaying App Group Lifecycle Plugin configuration and status. + * Used in Read pages (view mode). + */ + +import * as React from 'react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableRow from '@mui/material/TableRow'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +import { + useGetAppGroupLifecyclePlugins, + useGetAppGroupLifecyclePluginAppConfigProperties, + useGetAppGroupLifecyclePluginGroupConfigProperties, + useGetAppGroupLifecyclePluginAppStatusProperties, + useGetAppGroupLifecyclePluginGroupStatusProperties, + PluginConfigProperties, + PluginStatusProperties, +} from '../api/apiComponents'; + +type PluginData = { + [propertyId: string]: any; +}; + +interface AppGroupLifecyclePluginDataProps { + /** + * Entity type at which the plugin is configured ('app' or 'group') + */ + entityType: 'app' | 'group'; + + /** + * Plugin ID to display + */ + pluginId: string; + + /** + * Current configuration values + */ + currentConfig?: PluginData; + + /** + * Current status values (read-only) + */ + currentStatus?: PluginData; +} + +function PluginDataPropertiesTable({ + type, + properties, + data, +}: { + type: 'Configuration' | 'Status'; + properties: PluginConfigProperties | PluginStatusProperties; + data: PluginData; +}) { + return ( + + + {type} + + + + + {Object.entries(properties).map(([propertyId, property]) => { + const value = data[propertyId]; + let displayValue = '—'; + if (value != null) { + switch (property.type) { + case 'date': + try { + displayValue = new Date(value).toLocaleString(); + } catch { + displayValue = String(value); + } + break; + case 'boolean': + displayValue = value ? 'Yes' : 'No'; + break; + default: + displayValue = String(value); + } + } + + const row = ( + + + {property.display_name} + + + {displayValue} + + + ); + + return property.help_text ? ( + + {row} + + ) : ( + row + ); + })} + +
+
+
+ ); +} + +export default function AppGroupLifecyclePluginData({ + entityType, + pluginId, + currentConfig = {}, + currentStatus = {}, +}: AppGroupLifecyclePluginDataProps) { + const [expanded, setExpanded] = React.useState(false); + const {data: plugins, isLoading: pluginsLoading} = useGetAppGroupLifecyclePlugins(); + + const useConfigPropertiesHook = + entityType === 'app' + ? useGetAppGroupLifecyclePluginAppConfigProperties + : useGetAppGroupLifecyclePluginGroupConfigProperties; + const useStatusPropertiesHook = + entityType === 'app' + ? useGetAppGroupLifecyclePluginAppStatusProperties + : useGetAppGroupLifecyclePluginGroupStatusProperties; + + const {data: configProperties, isLoading: configLoading} = useConfigPropertiesHook( + {pathParams: {pluginId: pluginId}}, + {enabled: !!pluginId}, + ); + + const {data: statusProperties, isLoading: statusLoading} = useStatusPropertiesHook( + {pathParams: {pluginId: pluginId}}, + {enabled: !!pluginId}, + ); + + const selectedPlugin = React.useMemo(() => { + if (!plugins || !pluginId) return null; + return plugins.find((p) => p.id === pluginId) || null; + }, [plugins, pluginId]); + + if (pluginsLoading) { + return ( + + + + ); + } + + if (!selectedPlugin) { + return null; + } + + const hasConfig = configProperties && Object.keys(configProperties).length > 0; + const hasStatus = statusProperties && Object.keys(statusProperties).length > 0; + + return ( + + setExpanded(newExpanded)}> + }> + + + + Associated App Group Lifecycle Plugin: {selectedPlugin.display_name} + + + {selectedPlugin.description} + + + + + + + + + + {configLoading || statusLoading ? ( + + + + ) : hasConfig || hasStatus ? ( + + {hasConfig && ( + + )} + {hasStatus && ( + + )} + + ) : ( + + No configuration or status information available for this plugin. + + )} + + + +
+
+
+
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index 2a88fb29..d289b92c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -21,6 +21,15 @@ const queryClient = new QueryClient({ }, }); +// Cache plugin metadata indefinitely since it doesn't change while the app is running +queryClient.setQueryDefaults(['api', 'plugins'], { + staleTime: Infinity, + cacheTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +}); + if (['production', 'staging'].includes(import.meta.env.MODE)) { // Use a placeholder DSN as we'll be using the tunnel to proxy all Sentry React errors Sentry.init({ diff --git a/src/pages/apps/CreateUpdate.tsx b/src/pages/apps/CreateUpdate.tsx index 1d3176c2..cd89e30f 100644 --- a/src/pages/apps/CreateUpdate.tsx +++ b/src/pages/apps/CreateUpdate.tsx @@ -29,6 +29,7 @@ import { import {App, AppTagMap, OktaUser, Tag} from '../../api/apiSchemas'; import {isAccessAdmin, isAppOwnerGroupOwner, ACCESS_APP_RESERVED_NAME} from '../../authorization'; import accessConfig from '../../config/accessConfig'; +import AppGroupLifecyclePluginConfigurationForm from '../../components/AppGroupLifecyclePluginConfigurationForm'; interface AppButtonProps { setOpen(open: boolean): any; @@ -70,6 +71,11 @@ function AppDialog(props: AppDialogProps) { const [requestError, setRequestError] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); + const [selectedAppGroupLifecyclePluginId, setSelectedAppGroupLifecyclePluginId] = React.useState( + (props.app as any)?.app_group_lifecycle_plugin || null, + ); + const isAllowedToConfigureAppGroupLifecyclePlugin = isAccessAdmin(props.currentUser); + const complete = ( completedApp: App | undefined, error: CreateAppError | PutAppByIdError | null, @@ -115,6 +121,10 @@ function AppDialog(props: AppDialogProps) { app.tags_to_add = selectedTags.map((tag: Tag) => tag.id); } + if (isAllowedToConfigureAppGroupLifecyclePlugin) { + (app as any).app_group_lifecycle_plugin = selectedAppGroupLifecyclePluginId || null; + } + if (props.app == null) { createApp.mutate({body: app}); } else { @@ -206,6 +216,18 @@ function AppDialog(props: AppDialogProps) { renderInput={(params) => } /> + {isAllowedToConfigureAppGroupLifecyclePlugin && ( + + )} diff --git a/src/pages/apps/Read.tsx b/src/pages/apps/Read.tsx index ce2932d2..837997c6 100644 --- a/src/pages/apps/Read.tsx +++ b/src/pages/apps/Read.tsx @@ -3,6 +3,7 @@ import {useParams} from 'react-router-dom'; import Container from '@mui/material/Container'; import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; import {useGetAppById} from '../../api/apiComponents'; import {App, AppGroup} from '../../api/apiSchemas'; @@ -12,6 +13,7 @@ import NotFound from '../NotFound'; import Loading from '../../components/Loading'; import {AppsAccordionListGroup, AppsAdminActionGroup, AppsHeader} from './components/'; import ChangeTitle from '../../tab-title'; +import AppGroupLifecyclePluginData from '../../components/AppGroupLifecyclePluginData'; export default function ReadApp() { const currentUser = useCurrentUser(); @@ -67,6 +69,24 @@ export default function ReadApp() { + {(app as any)?.app_group_lifecycle_plugin && ( + + + + )} {app.active_owner_app_groups && ( , newValue: boolean | null) => { - console.log(newValue); if (newValue == null) { setSearchParams((params) => { params.delete('owner'); diff --git a/src/pages/groups/CreateUpdate.tsx b/src/pages/groups/CreateUpdate.tsx index 5dd04c74..dcc69851 100644 --- a/src/pages/groups/CreateUpdate.tsx +++ b/src/pages/groups/CreateUpdate.tsx @@ -32,6 +32,7 @@ import { import {PolymorphicGroup, AppGroup, App, OktaUser, Tag, OktaGroupTagMap} from '../../api/apiSchemas'; import {canManageGroup, isAccessAdmin, isAppOwnerGroupOwner} from '../../authorization'; import accessConfig from '../../config/accessConfig'; +import AppGroupLifecyclePluginConfigurationForm from '../../components/AppGroupLifecyclePluginConfigurationForm'; interface GroupButtonProps { defaultGroupType: 'okta_group' | 'app_group' | 'role_group'; @@ -98,6 +99,16 @@ function GroupDialog(props: GroupDialogProps) { const [requestError, setRequestError] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); + const appGroupLifecyclePluginId = React.useMemo(() => { + if (groupType !== 'app_group') return null; + const app = props.app ?? (props.group as AppGroup)?.app; + return (app as any)?.app_group_lifecycle_plugin || null; + }, [groupType, props.app, props.group]); + + const isAllowedToConfigureAppGroupLifecyclePlugin = + isAccessAdmin(props.currentUser) || + isAppOwnerGroupOwner(props.currentUser, props.app?.id ?? (props.group as AppGroup)?.app?.id ?? ''); + const complete = ( completedGroup: PolymorphicGroup | undefined, error: CreateGroupError | PutGroupByIdError | null, @@ -321,6 +332,17 @@ function GroupDialog(props: GroupDialogProps) { renderInput={(params) => } /> + {appGroupLifecyclePluginId && isAllowedToConfigureAppGroupLifecyclePlugin && ( + + )} diff --git a/src/pages/groups/Read.tsx b/src/pages/groups/Read.tsx index 1fbc6ab9..43a27912 100644 --- a/src/pages/groups/Read.tsx +++ b/src/pages/groups/Read.tsx @@ -57,6 +57,7 @@ import {Diversity3 as RoleIcon} from '@mui/icons-material'; import AppLinkButton from './AppLinkButton'; import AvatarButton from '../../components/AvatarButton'; import MembershipChip from '../../components/MembershipChip'; +import AppGroupLifecyclePluginData from '../../components/AppGroupLifecyclePluginData'; function sortGroupMembers( [aUserId, aUsers]: [string, Array], @@ -294,6 +295,24 @@ export default function ReadGroup() { + {group.type === 'app_group' && (app as any)?.app_group_lifecycle_plugin && ( + + + + )} {group.type == 'role_group' ? ( <> diff --git a/src/pages/roles/Audit.tsx b/src/pages/roles/Audit.tsx index 60a03ec7..6e9308db 100644 --- a/src/pages/roles/Audit.tsx +++ b/src/pages/roles/Audit.tsx @@ -173,7 +173,6 @@ export default function AuditRole() { }; const handleOwnerOrMember = (event: React.MouseEvent, newValue: boolean | null) => { - console.log(newValue); if (newValue == null) { setSearchParams((params) => { params.delete('owner'); diff --git a/src/pages/users/Audit.tsx b/src/pages/users/Audit.tsx index a4cc99cc..593d524b 100644 --- a/src/pages/users/Audit.tsx +++ b/src/pages/users/Audit.tsx @@ -175,7 +175,6 @@ export default function AuditUser() { }; const handleOwnerOrMember = (event: React.MouseEvent, newValue: boolean | null) => { - console.log(newValue); if (newValue == null) { setSearchParams((params) => { params.delete('owner'); diff --git a/tests/test_app_group_lifecycle_plugin.py b/tests/test_app_group_lifecycle_plugin.py new file mode 100644 index 00000000..1fa6e8f6 --- /dev/null +++ b/tests/test_app_group_lifecycle_plugin.py @@ -0,0 +1,852 @@ +""" +Tests for the App Group Lifecycle Plugin functionality. + +This includes tests for: +- Plugin registration and discovery +- Plugin configuration and validation +- API endpoints for plugin configuration +- Authorization checks for plugin configuration (positive and negative cases) +- Plugin lifecycle hooks +""" + +from typing import Any, Generator + +import pytest +from flask import Flask, url_for +from flask.testing import FlaskClient +from flask_sqlalchemy import SQLAlchemy +from pytest_mock import MockerFixture +from sqlalchemy.orm import Session + +from api.models import AppGroup, OktaUser +from api.plugins.app_group_lifecycle import ( + AppGroupLifecyclePluginConfigProperty, + AppGroupLifecyclePluginMetadata, + AppGroupLifecyclePluginStatusProperty, + 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_plugins, + get_config_value, + get_status_value, + hookimpl, + merge_app_lifecycle_plugin_data, + set_status_value, + validate_app_group_lifecycle_plugin_app_config, + validate_app_group_lifecycle_plugin_group_config, +) +from api.services import okta +from tests.factories import AppFactory, AppGroupFactory, OktaUserFactory + + +class DummyPlugin: + """A simple test plugin for unit testing.""" + + ID: str = "test_plugin" + + def __init__(self) -> None: + self.group_created_calls: list[str] = [] + self.group_deleted_calls: list[str] = [] + self.members_added_calls: list[tuple[str, list[str]]] = [] + self.members_removed_calls: list[tuple[str, list[str]]] = [] + + @hookimpl + def get_plugin_metadata(self) -> AppGroupLifecyclePluginMetadata | None: + return AppGroupLifecyclePluginMetadata( + id=self.ID, + display_name="Test Plugin", + description="A test plugin for unit testing", + ) + + @hookimpl + def get_plugin_app_config_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginConfigProperty] | None: + if plugin_id is not None and plugin_id != self.ID: + return None + + return { + "enabled": AppGroupLifecyclePluginConfigProperty( + display_name="Enabled", + help_text="Enable or disable the plugin", + type="boolean", + default_value=True, + required=True, + ), + "category": AppGroupLifecyclePluginConfigProperty( + display_name="Category", + help_text="Group category for the external service", + type="text", + required=False, + ), + } + + @hookimpl + def validate_plugin_app_config(self, config: dict[str, Any], plugin_id: str | None = None) -> dict[str, str] | None: + if plugin_id is not None and plugin_id != self.ID: + return None + + errors: dict[str, str] = {} + if "enabled" not in config: + errors["enabled"] = "The 'enabled' field is required" + elif not isinstance(config["enabled"], bool): + errors["enabled"] = "The 'enabled' field must be a boolean" + + if "category" in config and not isinstance(config["category"], str): + errors["category"] = "The 'category' field must be a string" + + return errors + + @hookimpl + def get_plugin_group_config_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginConfigProperty] | None: + if plugin_id is not None and plugin_id != self.ID: + return None + + return { + "group_id": AppGroupLifecyclePluginConfigProperty( + display_name="External Group ID", + help_text="The ID of the group in the external system", + type="text", + required=True, + ), + } + + @hookimpl + def validate_plugin_group_config( + self, config: dict[str, Any], plugin_id: str | None = None + ) -> dict[str, str] | None: + if plugin_id is not None and plugin_id != self.ID: + return None + + errors: dict[str, str] = {} + if "group_id" not in config: + errors["group_id"] = "The 'group_id' field is required" + elif not isinstance(config["group_id"], str): + errors["group_id"] = "The 'group_id' field must be a string" + + return errors + + @hookimpl + def get_plugin_app_status_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginStatusProperty] | None: + if plugin_id is not None and plugin_id != self.ID: + return None + + return { + "last_sync": AppGroupLifecyclePluginStatusProperty( + display_name="Last Sync", + help_text="When the last sync occurred", + type="date", + ), + } + + @hookimpl + def get_plugin_group_status_properties( + self, plugin_id: str | None = None + ) -> dict[str, AppGroupLifecyclePluginStatusProperty] | None: + if plugin_id is not None and plugin_id != self.ID: + return None + + return { + "member_count": AppGroupLifecyclePluginStatusProperty( + display_name="Member Count", + help_text="Number of members in the external group", + type="number", + ), + } + + @hookimpl + def group_created(self, session: Session, group: AppGroup, plugin_id: str | None = None) -> None: + if plugin_id is not None and plugin_id != self.ID: + return + # Track that this hook was called + self.group_created_calls.append(group.id) + + @hookimpl + def group_deleted(self, session: Session, group: AppGroup, plugin_id: str | None = None) -> None: + if plugin_id is not None and plugin_id != self.ID: + return + self.group_deleted_calls.append(group.id) + + @hookimpl + def group_members_added( + self, session: Session, group: AppGroup, members: list[OktaUser], plugin_id: str | None = None + ) -> None: + if plugin_id is not None and plugin_id != self.ID: + return + self.members_added_calls.append((group.id, [m.id for m in members])) + + @hookimpl + def group_members_removed( + self, session: Session, group: AppGroup, members: list[OktaUser], plugin_id: str | None = None + ) -> None: + if plugin_id is not None and plugin_id != self.ID: + return + self.members_removed_calls.append((group.id, [m.id for m in members])) + + +@pytest.fixture +def test_plugin(app: Flask, mocker: MockerFixture) -> Generator[DummyPlugin, None, None]: + """Register the test plugin for testing.""" + import pluggy + + import api.plugins.app_group_lifecycle as plugin_module + from api.plugins.app_group_lifecycle import AppGroupLifecyclePluginSpec + + # Create a new PluginManager with our test plugin + test_plugin_instance = DummyPlugin() + pm = pluggy.PluginManager(plugin_module.app_group_lifecycle_plugin_name) + pm.add_hookspecs(AppGroupLifecyclePluginSpec) + pm.register(plugin_module) # Register the hook wrappers + pm.register(test_plugin_instance, name=DummyPlugin.ID) + + # Mock the hook getter to return our test PluginManager's hook + mocker.patch.object(plugin_module, "_cached_app_group_lifecycle_hook", pm.hook) + mocker.patch.object(plugin_module, "_cached_plugin_registry", None) + + yield test_plugin_instance + + # Reset caches + plugin_module._cached_app_group_lifecycle_hook = None + plugin_module._cached_plugin_registry = None + + +class TestPluginRegistration: + """Tests for plugin registration and discovery.""" + + def test_plugin_metadata(self, app: Flask, test_plugin: DummyPlugin) -> None: + """Test that plugin metadata is correctly retrieved.""" + plugins = get_app_group_lifecycle_plugins() + test_plugin_meta = next((p for p in plugins if p.id == DummyPlugin.ID), None) + assert test_plugin_meta is not None + assert test_plugin_meta.display_name == "Test Plugin" + assert test_plugin_meta.description == "A test plugin for unit testing" + + def test_get_plugin_config_properties(self, app: Flask, test_plugin: DummyPlugin) -> None: + """Test retrieving plugin configuration properties.""" + app_props = get_app_group_lifecycle_plugin_app_config_properties(DummyPlugin.ID) + assert "enabled" in app_props + assert "category" in app_props + assert app_props["enabled"].required is True + assert app_props["category"].required is False + + +class TestPluginAPIEndpoints: + """Tests for plugin-related API endpoints.""" + + def test_list_plugins(self, client: FlaskClient, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test GET /api/plugins/app-group-lifecycle returns all plugins.""" + url = url_for("api-plugins.app_group_lifecycle_plugins") + response = client.get(url) + assert response.status_code == 200 + + data = response.get_json() + assert isinstance(data, list) + plugin_ids = [p["id"] for p in data] + assert DummyPlugin.ID in plugin_ids + + def test_get_app_config_properties(self, client: FlaskClient, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test GET /api/plugins/app-group-lifecycle//app-config-props.""" + url = url_for("api-plugins.app_group_lifecycle_plugin_app_config_props", plugin_id=DummyPlugin.ID) + response = client.get(url) + assert response.status_code == 200 + + data = response.get_json() + assert "enabled" in data + assert "category" in data + assert data["enabled"]["required"] is True + + def test_get_group_config_properties(self, client: FlaskClient, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test GET /api/plugins/app-group-lifecycle//group-config-props.""" + url = url_for("api-plugins.app_group_lifecycle_plugin_group_config_props", plugin_id=DummyPlugin.ID) + response = client.get(url) + assert response.status_code == 200 + + data = response.get_json() + assert "group_id" in data + assert data["group_id"]["required"] is True + + def test_get_app_status_properties(self, client: FlaskClient, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test GET /api/plugins/app-group-lifecycle//app-status-props.""" + url = url_for("api-plugins.app_group_lifecycle_plugin_app_status_props", plugin_id=DummyPlugin.ID) + response = client.get(url) + assert response.status_code == 200 + + data = response.get_json() + assert "last_sync" in data + + def test_get_group_status_properties(self, client: FlaskClient, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test GET /api/plugins/app-group-lifecycle//group-status-props.""" + url = url_for("api-plugins.app_group_lifecycle_plugin_group_status_props", plugin_id=DummyPlugin.ID) + response = client.get(url) + assert response.status_code == 200 + + data = response.get_json() + assert "member_count" in data + + def test_get_nonexistent_plugin(self, client: FlaskClient, db: SQLAlchemy) -> None: + """Test that requesting a non-existent plugin returns 404.""" + url = url_for("api-plugins.app_group_lifecycle_plugin_app_config_props", plugin_id="nonexistent_plugin") + response = client.get(url) + assert response.status_code == 404 + + +class TestPluginConfigAuthorization: + """Tests for plugin configuration authorization - positive cases (should succeed).""" + + def test_access_admin_can_configure_plugin_at_app_level( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin + ) -> None: + """Test that Access admins can configure plugins on apps.""" + # Use the default Access admin user (wumpus@discord.com) created in conftest + # No need to create a new user or modify app.config["CURRENT_OKTA_USER_EMAIL"] + test_app = AppFactory.build(name="TestApp", description="Test App") + + db.session.add(test_app) + db.session.commit() + + # Configure plugin on the test app + url = url_for("api-apps.app_by_id", app_id=test_app.id) + data = { + "name": test_app.name, + "app_group_lifecycle_plugin": DummyPlugin.ID, + "plugin_data": {DummyPlugin.ID: {"configuration": {"enabled": True, "category": "test_id"}}}, + } + + response = client.put(url, json=data) + assert response.status_code == 200 + + response_data = response.get_json() + assert response_data["app_group_lifecycle_plugin"] == DummyPlugin.ID + assert response_data["plugin_data"][DummyPlugin.ID]["configuration"]["enabled"] is True + + def test_app_owner_cannot_configure_plugin_at_app_level( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin + ) -> None: + """Test that app owners (non-Access admins) cannot configure plugins on apps.""" + # Create app owner user + from api.models import OktaUserGroupMember + + app_owner = OktaUserFactory.build() + test_app = AppFactory.build(name="TestApp2", description="Test App 2") + test_app_owner_group = AppGroupFactory.build( + app_id=test_app.id, + is_owner=True, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}{AppGroup.APP_OWNERS_GROUP_NAME_SUFFIX}", + ) + + db.session.add(app_owner) + db.session.add(test_app) + db.session.add(test_app_owner_group) + + # Make the user an owner of the test app (but not Access admin) by directly adding membership + membership = OktaUserGroupMember(user_id=app_owner.id, group_id=test_app_owner_group.id, is_owner=True) + db.session.add(membership) + db.session.commit() + + # Set current user to app owner + app.config["CURRENT_OKTA_USER_EMAIL"] = app_owner.email + + # Try to configure plugin on the test app + url = url_for("api-apps.app_by_id", app_id=test_app.id) + data = { + "name": test_app.name, + "app_group_lifecycle_plugin": DummyPlugin.ID, + "plugin_data": {DummyPlugin.ID: {"configuration": {"enabled": True, "category": "test_id"}}}, + } + + response = client.put(url, json=data) + assert response.status_code == 403 + + def test_app_owner_cannot_modify_existing_plugin_config_at_app_level( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin + ) -> None: + """Test that app owners cannot modify existing plugin configuration.""" + # Create app owner + from api.models import OktaUserGroupMember + + app_owner = OktaUserFactory.build() + test_app = AppFactory.build( + name="TestApp3", + description="Test App 3", + app_group_lifecycle_plugin=DummyPlugin.ID, + plugin_data={DummyPlugin.ID: {"configuration": {"enabled": True, "category": "original_id"}}}, + ) + test_app_owner_group = AppGroupFactory.build( + app_id=test_app.id, + is_owner=True, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}{AppGroup.APP_OWNERS_GROUP_NAME_SUFFIX}", + ) + + db.session.add(app_owner) + db.session.add(test_app) + db.session.add(test_app_owner_group) + + # Make app_owner an owner of the test app by directly adding membership + membership = OktaUserGroupMember(user_id=app_owner.id, group_id=test_app_owner_group.id, is_owner=True) + db.session.add(membership) + db.session.commit() + + # Set current user to app owner + app.config["CURRENT_OKTA_USER_EMAIL"] = app_owner.email + + # Try to modify plugin configuration + url = url_for("api-apps.app_by_id", app_id=test_app.id) + data = { + "name": test_app.name, + "plugin_data": { + DummyPlugin.ID: { + "configuration": { + "enabled": False, # Changed + "category": "modified_id", # Changed + } + } + }, + } + + response = client.put(url, json=data) + assert response.status_code == 403 + + def test_access_admin_can_configure_plugin_at_group_level( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin, mocker: MockerFixture + ) -> None: + """Test that Access admins can configure plugins on groups.""" + # Use the default Access admin user (wumpus@discord.com) created in conftest + test_app = AppFactory.build( + name="TestApp4", description="Test App 4", app_group_lifecycle_plugin=DummyPlugin.ID + ) + test_group = AppGroupFactory.build( + app_id=test_app.id, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}Testgroup", + ) + + db.session.add(test_app) + db.session.add(test_group) + db.session.commit() + + # Mock Okta update_group call + mocker.patch.object(okta, "update_group") + + # Configure plugin on the group + url = url_for("api-groups.group_by_id", group_id=test_group.id) + data = { + "type": "app_group", + "name": test_group.name, + "description": "", + "app_id": test_group.app_id, + "plugin_data": {DummyPlugin.ID: {"configuration": {"group_id": "external_group_123"}}}, + } + + response = client.put(url, json=data) + assert response.status_code == 200 + + def test_app_owner_can_configure_plugin_at_group_level( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin, mocker: MockerFixture + ) -> None: + """Test that app owners can configure plugins on their app's groups.""" + # Create app owner + from api.models import OktaUserGroupMember + + app_owner = OktaUserFactory.build() + test_app = AppFactory.build( + name="TestApp5", description="Test App 5", app_group_lifecycle_plugin=DummyPlugin.ID + ) + test_app_owner_group = AppGroupFactory.build( + app_id=test_app.id, + is_owner=True, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}{AppGroup.APP_OWNERS_GROUP_NAME_SUFFIX}", + ) + test_group = AppGroupFactory.build( + app_id=test_app.id, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}Testgroup2", + ) + + db.session.add(app_owner) + db.session.add(test_app) + db.session.add(test_app_owner_group) + db.session.add(test_group) + + # Make user app owner by directly adding membership + membership = OktaUserGroupMember(user_id=app_owner.id, group_id=test_app_owner_group.id, is_owner=True) + db.session.add(membership) + db.session.commit() + + # Set current user + app.config["CURRENT_OKTA_USER_EMAIL"] = app_owner.email + + # Mock Okta update_group call + mocker.patch.object(okta, "update_group") + + # Configure plugin on the group + url = url_for("api-groups.group_by_id", group_id=test_group.id) + data = { + "type": "app_group", + "name": test_group.name, + "description": "", + "app_id": test_group.app_id, + "plugin_data": {DummyPlugin.ID: {"configuration": {"group_id": "external_group_456"}}}, + } + + response = client.put(url, json=data) + assert response.status_code == 200 + + def test_group_owner_cannot_configure_plugin_at_group_level( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin + ) -> None: + """Test that group owners (non-app owners) cannot configure plugins on groups.""" + # Create group owner (but not app owner) + from api.models import OktaUserGroupMember + + group_owner = OktaUserFactory.build() + test_app = AppFactory.build( + name="TestApp6", description="Test App 6", app_group_lifecycle_plugin=DummyPlugin.ID + ) + test_group = AppGroupFactory.build( + app_id=test_app.id, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}Testgroup3", + ) + + db.session.add(group_owner) + db.session.add(test_app) + db.session.add(test_group) + + # Make user a group owner (not app owner) by directly adding membership + membership = OktaUserGroupMember(user_id=group_owner.id, group_id=test_group.id, is_owner=True) + db.session.add(membership) + db.session.commit() + + # Set current user + app.config["CURRENT_OKTA_USER_EMAIL"] = group_owner.email + + # Try to configure plugin on the group + url = url_for("api-groups.group_by_id", group_id=test_group.id) + data = { + "type": "app_group", + "name": test_group.name, + "description": "", + "app_id": test_group.app_id, + "plugin_data": {DummyPlugin.ID: {"configuration": {"group_id": "external_group_789"}}}, + } + + response = client.put(url, json=data) + assert response.status_code == 403 + + +class TestPluginHelperFunctions: + """Tests for plugin helper functions like get_config_value, set_status_value, etc.""" + + def test_get_config_value(self, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test getting configuration values from plugin data.""" + test_app = AppFactory.build( + name="TestApp7", + plugin_data={DummyPlugin.ID: {"configuration": {"enabled": True, "category": "test_id_123"}}}, + ) + db.session.add(test_app) + db.session.commit() + + enabled = get_config_value(test_app, "enabled", DummyPlugin.ID) + category = get_config_value(test_app, "category", DummyPlugin.ID) + + assert enabled is True + assert category == "test_id_123" + + def test_get_status_value(self, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test getting status values from plugin data.""" + test_app = AppFactory.build( + name="TestApp8", + plugin_data={DummyPlugin.ID: {"status": {"last_sync": "2025-01-15T10:30:00Z", "sync_count": 42}}}, + ) + db.session.add(test_app) + db.session.commit() + + last_sync = get_status_value(test_app, "last_sync", DummyPlugin.ID) + sync_count = get_status_value(test_app, "sync_count", DummyPlugin.ID) + + assert last_sync == "2025-01-15T10:30:00Z" + assert sync_count == 42 + + def test_set_status_value(self, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test setting status values in plugin data.""" + test_app = AppFactory.build(name="TestApp9", plugin_data={}) + db.session.add(test_app) + db.session.commit() + + set_status_value(test_app, "last_sync", "2025-01-15T11:00:00Z", DummyPlugin.ID) + db.session.commit() + + # Refresh from DB + db.session.expire(test_app) + + last_sync = get_status_value(test_app, "last_sync", DummyPlugin.ID) + assert last_sync == "2025-01-15T11:00:00Z" + + +class TestPluginValidation: + """Tests for plugin configuration validation.""" + + def test_valid_app_config(self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin) -> None: + """Test that valid app configuration is accepted.""" + test_app = AppFactory.build(name="TestApp10") + + db.session.add(test_app) + db.session.commit() + + # Valid configuration + url = url_for("api-apps.app_by_id", app_id=test_app.id) + data = { + "name": test_app.name, + "app_group_lifecycle_plugin": DummyPlugin.ID, + "plugin_data": {DummyPlugin.ID: {"configuration": {"enabled": True, "category": "valid_id"}}}, + } + + response = client.put(url, json=data) + assert response.status_code == 200 + + def test_invalid_app_config( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin + ) -> None: + """Test that invalid app configuration is rejected.""" + test_app = AppFactory.build(name="TestApp11") + + db.session.add(test_app) + db.session.commit() + + # Invalid configuration (missing required 'enabled' field) + url = url_for("api-apps.app_by_id", app_id=test_app.id) + data = { + "name": test_app.name, + "app_group_lifecycle_plugin": DummyPlugin.ID, + "plugin_data": { + DummyPlugin.ID: { + "configuration": { + "category": "some_id" + # Missing 'enabled' which is required + } + } + }, + } + + response = client.put(url, json=data) + assert response.status_code == 400 + assert "enabled" in str(response.get_json()) + + def test_valid_group_config( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin, mocker: MockerFixture + ) -> None: + """Test that valid group configuration is accepted.""" + test_app = AppFactory.build(name="TestApp12", app_group_lifecycle_plugin=DummyPlugin.ID) + test_group = AppGroupFactory.build( + app_id=test_app.id, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}Testgroup", + ) + + db.session.add(test_app) + db.session.add(test_group) + db.session.commit() + + # Mock Okta update_group call + mocker.patch.object(okta, "update_group") + + # Valid configuration + url = url_for("api-groups.group_by_id", group_id=test_group.id) + data = { + "type": "app_group", + "name": test_group.name, + "description": "", + "app_id": test_group.app_id, + "plugin_data": {DummyPlugin.ID: {"configuration": {"group_id": "external_123"}}}, + } + + response = client.put(url, json=data) + assert response.status_code == 200 + + def test_invalid_group_config( + self, client: FlaskClient, db: SQLAlchemy, app: Flask, test_plugin: DummyPlugin + ) -> None: + """Test that invalid group configuration is rejected.""" + test_app = AppFactory.build(name="TestApp13", app_group_lifecycle_plugin=DummyPlugin.ID) + test_group = AppGroupFactory.build( + app_id=test_app.id, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}Testgroup2", + ) + + db.session.add(test_app) + db.session.add(test_group) + db.session.commit() + + # Invalid configuration (missing required 'group_id' field) + url = url_for("api-groups.group_by_id", group_id=test_group.id) + data = { + "type": "app_group", + "name": test_group.name, + "description": "", + "app_id": test_group.app_id, + "plugin_data": { + DummyPlugin.ID: { + "configuration": { + # Missing 'group_id' which is required + } + } + }, + } + + response = client.put(url, json=data) + assert response.status_code == 400 + assert "group_id" in str(response.get_json()) + + +class TestPluginDataRestore: + """Tests for the restore_unchanged_app_lifecycle_plugin_data function.""" + + def test_restore_unchanged_app_data(self, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test that restore function merges configuration updates while preserving status.""" + # Create an app with existing plugin data (this simulates the OLD state before update) + test_app = AppFactory.build(name="TestAppRestore1") + db.session.add(test_app) + db.session.commit() + + # Save the OLD complete plugin data (before the update) + old_plugin_data = { + DummyPlugin.ID: { + "configuration": {"enabled": True, "category": "original"}, + "status": {"last_sync": "2025-01-01T00:00:00Z", "sync_count": 10}, + } + } + + # Simulate a partial update from the request (NEW data - only configuration changes) + test_app.plugin_data = { + DummyPlugin.ID: { + "configuration": {"enabled": False}, # Partial update - only changed field + "status": {}, # No status changes from request + } + } + + # Restore should merge the NEW configuration into OLD while preserving status + merge_app_lifecycle_plugin_data(test_app, old_plugin_data) + + # Check that configuration was updated with the new value + assert test_app.plugin_data[DummyPlugin.ID]["configuration"]["enabled"] is False + # Check that unchanged configuration field was preserved + assert test_app.plugin_data[DummyPlugin.ID]["configuration"]["category"] == "original" + # Check that status was preserved from old data + assert test_app.plugin_data[DummyPlugin.ID]["status"]["last_sync"] == "2025-01-01T00:00:00Z" + assert test_app.plugin_data[DummyPlugin.ID]["status"]["sync_count"] == 10 + + def test_restore_unchanged_group_data(self, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test that restore function works with app groups.""" + test_app = AppFactory.build(name="TestAppRestore2", app_group_lifecycle_plugin=DummyPlugin.ID) + test_group = AppGroupFactory.build( + app_id=test_app.id, + name=f"{AppGroup.APP_GROUP_NAME_PREFIX}{test_app.name}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}RestoreTest", + ) + db.session.add(test_app) + db.session.add(test_group) + db.session.commit() + + # Save the OLD complete plugin data (before the update) + old_plugin_data = { + DummyPlugin.ID: {"configuration": {"group_id": "external_123"}, "status": {"member_count": 5}} + } + + # Simulate a partial update from the request (NEW data) + test_group.plugin_data = { + DummyPlugin.ID: { + "configuration": {"group_id": "external_456"}, # Changed value + "status": {}, # No status changes from request + } + } + + merge_app_lifecycle_plugin_data(test_group, old_plugin_data) + + # Check configuration was updated with new value + assert test_group.plugin_data[DummyPlugin.ID]["configuration"]["group_id"] == "external_456" + # Check status was preserved from old data + assert test_group.plugin_data[DummyPlugin.ID]["status"]["member_count"] == 5 + + def test_restore_ignores_non_plugin_data(self, db: SQLAlchemy, test_plugin: DummyPlugin) -> None: + """Test that restore function only processes registered plugin IDs.""" + test_app = AppFactory.build(name="TestAppRestore3") + db.session.add(test_app) + db.session.commit() + + # OLD data includes both a non-existent plugin and the valid plugin + old_plugin_data = { + "non_existent_plugin": {"configuration": {"some_key": "some_value"}, "status": {"some_status": "value"}}, + DummyPlugin.ID: {"configuration": {"enabled": True}, "status": {"last_sync": "2025-01-01T00:00:00Z"}}, + } + + # NEW data from request (partial update) + test_app.plugin_data = {DummyPlugin.ID: {"configuration": {"enabled": False}, "status": {}}} + + # Should not error and should only process the registered plugin + merge_app_lifecycle_plugin_data(test_app, old_plugin_data) + + # Check the valid plugin was processed correctly + assert test_app.plugin_data[DummyPlugin.ID]["configuration"]["enabled"] is False + assert test_app.plugin_data[DummyPlugin.ID]["status"]["last_sync"] == "2025-01-01T00:00:00Z" + # The non-existent plugin should not be in the result + assert "non_existent_plugin" not in test_app.plugin_data + + +class TestPluginDirectFunctions: + """Tests for direct function calls to plugin functions.""" + + def test_get_group_config_properties(self, app: Flask, test_plugin: DummyPlugin) -> None: + """Test getting group-level configuration properties directly.""" + props = get_app_group_lifecycle_plugin_group_config_properties(DummyPlugin.ID) + + assert "group_id" in props + assert props["group_id"].required is True + assert props["group_id"].type == "text" + + def test_validate_app_config_direct(self, app: Flask, test_plugin: DummyPlugin) -> None: + """Test validating app configuration directly.""" + # Valid configuration + valid_config: dict[str, object] = {"enabled": True, "category": "test"} + plugin_data = {DummyPlugin.ID: {"configuration": valid_config, "status": {}}} + + errors = validate_app_group_lifecycle_plugin_app_config(plugin_data, DummyPlugin.ID) + assert errors == {} + + # Invalid configuration (missing required field) + invalid_config: dict[str, object] = {"category": "test"} # Missing "enabled" + plugin_data = {DummyPlugin.ID: {"configuration": invalid_config, "status": {}}} + + errors = validate_app_group_lifecycle_plugin_app_config(plugin_data, DummyPlugin.ID) + assert "enabled" in errors + + def test_validate_group_config_direct(self, app: Flask, test_plugin: DummyPlugin) -> None: + """Test validating group configuration directly.""" + # Valid configuration + valid_config: dict[str, object] = {"group_id": "external_123"} + plugin_data = {DummyPlugin.ID: {"configuration": valid_config, "status": {}}} + + errors = validate_app_group_lifecycle_plugin_group_config(plugin_data, DummyPlugin.ID) + assert errors == {} + + # Invalid configuration (missing required field) + invalid_config: dict[str, object] = {} # Missing "group_id" + plugin_data = {DummyPlugin.ID: {"configuration": invalid_config, "status": {}}} + + errors = validate_app_group_lifecycle_plugin_group_config(plugin_data, DummyPlugin.ID) + assert "group_id" in errors + + def test_get_app_status_properties(self, app: Flask, test_plugin: DummyPlugin) -> None: + """Test getting app-level status properties directly.""" + props = get_app_group_lifecycle_plugin_app_status_properties(DummyPlugin.ID) + + assert "last_sync" in props + assert props["last_sync"].type == "date" + assert props["last_sync"].display_name == "Last Sync" + + def test_get_group_status_properties(self, app: Flask, test_plugin: DummyPlugin) -> None: + """Test getting group-level status properties directly.""" + props = get_app_group_lifecycle_plugin_group_status_properties(DummyPlugin.ID) + + assert "member_count" in props + assert props["member_count"].type == "number" + assert props["member_count"].display_name == "Member Count"