diff --git a/api/models/core_models.py b/api/models/core_models.py index b66c3911..10006a7d 100644 --- a/api/models/core_models.py +++ b/api/models/core_models.py @@ -919,6 +919,18 @@ class GroupRequest(Base): default=list, server_default="[]", ) + # Plugin group config supplied with the request, in the same shape as a + # group's plugin_data: {plugin_id: {configuration: {...}}}. No status is + # stored -- the lifecycle hook populates that after the group is created. + requested_plugin_data: Mapped[Dict[str, Any]] = mapped_column( + mutable_json_type( + dbtype=JSON().with_variant(JSONB, "postgresql"), + nested=True, + ), + nullable=False, + default=dict, + server_default="{}", + ) # Will also be used to populate owner access reason field request_reason: Mapped[str] = mapped_column(Unicode(1024), nullable=False, default="") @@ -940,6 +952,17 @@ class GroupRequest(Base): default=list, server_default="[]", ) + # Resolver-editable copy of the plugin config; applied (falling back to + # requested_plugin_data) to the AppGroup created on approval. + resolved_plugin_data: Mapped[Dict[str, Any]] = mapped_column( + mutable_json_type( + dbtype=JSON().with_variant(JSONB, "postgresql"), + nested=True, + ), + nullable=False, + default=dict, + server_default="{}", + ) resolution_reason: Mapped[str] = mapped_column(Unicode(1024), nullable=False, default="") approved_group_id: Mapped[Optional[str]] = mapped_column( diff --git a/api/operations/approve_group_request.py b/api/operations/approve_group_request.py index 2630aa37..2bc4d57e 100644 --- a/api/operations/approve_group_request.py +++ b/api/operations/approve_group_request.py @@ -10,6 +10,7 @@ from api.extensions import db from api.models import ( AccessRequestStatus, + App, AppGroup, GroupRequest, OktaGroup, @@ -115,6 +116,11 @@ def execute(self) -> Optional[GroupRequest]: if group_request.resolved_group_tags else group_request.requested_group_tags ) + resolved_plugin_data = ( + group_request.resolved_plugin_data + if group_request.resolved_plugin_data + else group_request.requested_plugin_data + ) # authorization access_owner_ids = {u.id for u in get_access_owners()} @@ -199,6 +205,24 @@ def execute(self) -> Optional[GroupRequest]: description=resolved_description, app_id=resolved_app_id, ) + # Carry the request's plugin config onto the group so the + # group_created hook (fired inside CreateGroup) sees it. Re-validate + # defensively: the app or its config may have changed since filing. + if resolved_plugin_data: + resolved_app = db.session.get(App, resolved_app_id) + plugin_id = resolved_app.app_group_lifecycle_plugin if resolved_app is not None else None + if plugin_id is not None: + from api.plugins.app_group_lifecycle import ( + validate_app_group_lifecycle_plugin_group_config, + ) + + plugin_errors = validate_app_group_lifecycle_plugin_group_config( + resolved_plugin_data, + plugin_id, + ) + if plugin_errors: + raise ValueError(f"plugin_data: {plugin_errors}") + new_group.plugin_data = resolved_plugin_data else: new_group = OktaGroup( name=resolved_name, diff --git a/api/operations/create_group_request.py b/api/operations/create_group_request.py index 5ad6dc7f..53c365b0 100644 --- a/api/operations/create_group_request.py +++ b/api/operations/create_group_request.py @@ -38,6 +38,7 @@ def __init__( requested_group_tags: Optional[List[str]] = None, requested_ownership_ending_at: Optional[datetime] = None, request_reason: str = "", + requested_plugin_data: Optional[dict] = None, ): self.id = self.__generate_id() @@ -50,6 +51,7 @@ def __init__( self.requested_group_tags = requested_group_tags if requested_group_tags is not None else [] self.requested_ownership_ending_at = requested_ownership_ending_at self.request_reason = request_reason + self.requested_plugin_data = requested_plugin_data if requested_plugin_data is not None else {} self.conditional_access_hook = get_conditional_access_hook() self.notification_hook = get_notification_hook() @@ -124,6 +126,7 @@ def execute(self) -> Optional[GroupRequest]: requested_group_tags=self.requested_group_tags, requested_ownership_ending_at=coalesced_ownership_ending_at, request_reason=self.request_reason, + requested_plugin_data=self.requested_plugin_data, ) db.session.add(group_request) diff --git a/api/routers/group_requests.py b/api/routers/group_requests.py index 25865bdf..a40bbdee 100644 --- a/api/routers/group_requests.py +++ b/api/routers/group_requests.py @@ -177,6 +177,22 @@ def post_group_request( if app is None: raise HTTPException(404, "App not found") + # Validate any supplied plugin group config against the app's lifecycle + # plugin. old_plugin_data is None: a request always describes a not-yet- + # created group, so immutable fields stay freely settable. + if app.app_group_lifecycle_plugin is not None: + from api.plugins.app_group_lifecycle import validate_app_group_lifecycle_plugin_group_config + + try: + plugin_errors = validate_app_group_lifecycle_plugin_group_config( + body.requested_plugin_data, + app.app_group_lifecycle_plugin, + ) + except ValueError as e: + raise HTTPException(400, f"plugin_data: {e}") from e + if plugin_errors: + raise HTTPException(400, f"plugin_data: {plugin_errors}") + # Every requested tag id must resolve to a non-deleted tag. if body.requested_group_tags: tags = db.scalars( @@ -214,6 +230,7 @@ def post_group_request( requested_group_tags=body.requested_group_tags, requested_ownership_ending_at=body.requested_ownership_ending_at, request_reason=body.request_reason or "", + requested_plugin_data=body.requested_plugin_data if isinstance(body, _AppGroupRequestBody) else {}, ).execute() if gr is None: raise HTTPException(400, "Failed to create group request") @@ -276,6 +293,8 @@ def put_group_request( gr.resolved_group_tags = body.resolved_group_tags if body.resolved_ownership_ending_at is not None: gr.resolved_ownership_ending_at = body.resolved_ownership_ending_at + if body.resolved_plugin_data is not None: + gr.resolved_plugin_data = body.resolved_plugin_data db.commit() diff --git a/api/schemas/requests_schemas.py b/api/schemas/requests_schemas.py index 7b4532cb..31019c33 100644 --- a/api/schemas/requests_schemas.py +++ b/api/schemas/requests_schemas.py @@ -284,6 +284,8 @@ class GroupRequestDetail(BaseModel): resolution_reason: Optional[str] = "" resolved_at: Optional[FlexibleDatetime] = None approved_group_id: Optional[str] = None + requested_plugin_data: dict[str, Any] = Field(default_factory=dict) + resolved_plugin_data: dict[str, Any] = Field(default_factory=dict) created_at: FlexibleDatetime updated_at: FlexibleDatetime requester: Optional[OktaUserSummary] = None @@ -333,6 +335,7 @@ class _RoleGroupRequestBody(_GroupRequestBodyBase): class _AppGroupRequestBody(_GroupRequestBodyBase): requested_group_type: Literal["app_group"] requested_app_id: str + requested_plugin_data: dict[str, Any] = Field(default_factory=dict) CreateGroupRequestBody = Annotated[ @@ -351,6 +354,7 @@ class ResolveGroupRequestBody(BaseModel): resolved_app_id: Optional[str] = None resolved_group_tags: Optional[list[str]] = None resolved_ownership_ending_at: Optional[FlexibleDatetime] = None + resolved_plugin_data: Optional[dict[str, Any]] = None # --- Tags ------------------------------------------------------------------- diff --git a/migrations/versions/128a7f45cac6_group_request_plugin_data.py b/migrations/versions/128a7f45cac6_group_request_plugin_data.py new file mode 100644 index 00000000..cfc1ea13 --- /dev/null +++ b/migrations/versions/128a7f45cac6_group_request_plugin_data.py @@ -0,0 +1,43 @@ +"""group_request plugin_data + +Revision ID: 128a7f45cac6 +Revises: dc768a8ce1ad +Create Date: 2026-06-27 06:09:07.323973 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "128a7f45cac6" +down_revision = "dc768a8ce1ad" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "group_request", + sa.Column( + "requested_plugin_data", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + server_default="{}", + ), + ) + op.add_column( + "group_request", + sa.Column( + "resolved_plugin_data", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + server_default="{}", + ), + ) + + +def downgrade() -> None: + op.drop_column("group_request", "resolved_plugin_data") + op.drop_column("group_request", "requested_plugin_data") diff --git a/src/api/apiSchemas.ts b/src/api/apiSchemas.ts index d22ad5f5..107f8835 100644 --- a/src/api/apiSchemas.ts +++ b/src/api/apiSchemas.ts @@ -517,6 +517,12 @@ export type GroupRequestDetail = { resolution_reason?: string | null; resolved_at?: string | null; approved_group_id?: string | null; + requested_plugin_data?: { + [key: string]: any; + }; + resolved_plugin_data?: { + [key: string]: any; + }; created_at: string | null; updated_at: string | null; requester?: OktaUserSummary | null; @@ -1012,6 +1018,9 @@ export type ResolveGroupRequestBody = { resolved_app_id?: string | null; resolved_group_tags?: string[] | null; resolved_ownership_ending_at?: string | null; + resolved_plugin_data?: { + [key: string]: any; + } | null; }; export type ResolveRoleRequestBody = { @@ -1568,6 +1577,9 @@ export type AppGroupRequestBody = { request_reason?: string | null; requested_group_type: string; requested_app_id: string; + requested_plugin_data?: { + [key: string]: any; + }; }; export type AppGroupUpdateBody = { diff --git a/src/pages/group_requests/Create.tsx b/src/pages/group_requests/Create.tsx index 8fa95457..79fabbc5 100644 --- a/src/pages/group_requests/Create.tsx +++ b/src/pages/group_requests/Create.tsx @@ -30,10 +30,13 @@ import { import { useGroupRequestsCreate, useApps, + useAppById, useTags, GroupRequestsCreateError, GroupRequestsCreateVariables, } from '../../api/apiComponents'; +import AppGroupLifecyclePluginConfigurationForm from '../../components/AppGroupLifecyclePluginConfigurationForm'; +import {pluginIdForApp, extractRequestedPluginData} from './pluginConfig'; import { AppDetail, AppGroupDetail, @@ -154,6 +157,16 @@ function CreateRequestContainer(props: CreateRequestContainerProps) { [detectedAppName, detectedAppData], ); + // The app a submitted request would target: explicitly-selected app, or the + // one detected from an "App-…-" name prefix on an okta_group request. + const effectiveAppId = (groupType === 'app_group' ? selectedApp?.id : detectedApp?.id) ?? null; + // AppSummary (from search) lacks app_group_lifecycle_plugin; fetch AppDetail for it. + const {data: effectiveAppDetail} = useAppById( + {pathParams: {appId: effectiveAppId ?? ''}}, + {enabled: effectiveAppId != null}, + ); + const requestPluginId = pluginIdForApp(effectiveAppDetail); + const {data: tagSearchData} = useTags({ queryParams: {page: 1, size: 10, q: tagSearchInput}, }); @@ -195,6 +208,8 @@ function CreateRequestContainer(props: CreateRequestContainerProps) { groupName = ROLE_GROUP_PREFIX + formData.name; } + const requestedPluginData = extractRequestedPluginData(formData as any); + const body = { requested_group_name: groupName, requested_group_description: formData.description ?? '', @@ -202,6 +217,7 @@ function CreateRequestContainer(props: CreateRequestContainerProps) { requested_app_id: appId, request_reason: formData.reason ?? '', requested_group_tags: selectedTags.map((t) => t.id), + ...(effectiveType === 'app_group' && requestPluginId ? {requested_plugin_data: requestedPluginData} : {}), } as Parameters[0]['body']; if (body == null) { @@ -374,6 +390,9 @@ function CreateRequestContainer(props: CreateRequestContainerProps) { required /> + {requestPluginId && ( + + )} diff --git a/src/pages/group_requests/Read.tsx b/src/pages/group_requests/Read.tsx index ced8df07..b7ca898b 100644 --- a/src/pages/group_requests/Read.tsx +++ b/src/pages/group_requests/Read.tsx @@ -66,8 +66,10 @@ import {useCurrentUser} from '../../authentication'; import {isAccessAdmin, isAppOwnerGroupOwner} from '../../authorization'; import {displayUserName, minTagTime} from '../../helpers'; +import AppGroupLifecyclePluginConfigurationForm from '../../components/AppGroupLifecyclePluginConfigurationForm'; import Loading from '../../components/Loading'; import accessConfig from '../../config/accessConfig'; +import {pluginIdForApp, extractRequestedPluginData} from './pluginConfig'; import ChangeTitle from '../../tab-title'; import NotFound from '../NotFound'; @@ -222,6 +224,9 @@ export default function ReadGroupRequest() { {pathParams: {appId: requestedAppId ?? ''}}, {enabled: requestedAppId != null && requestedGroupType === 'app_group'}, ); + // The lifecycle plugin configured on the target app (null if none). + const requestPluginId = pluginIdForApp(requestedAppData); + const [appSeeded, setAppSeeded] = React.useState(false); React.useEffect(() => { if (!appSeeded && requestedAppData?.name) { @@ -471,6 +476,10 @@ export default function ReadGroupRequest() { reason: responseForm.reason ?? '', }; + if (responseForm.resolved_group_type === 'app_group' && requestPluginId) { + resolveRequest.resolved_plugin_data = extractRequestedPluginData(responseForm as any); + } + switch (responseForm.resolved_ownership_ending_at) { case 'indefinite': case undefined: @@ -916,6 +925,15 @@ export default function ReadGroupRequest() { ) : null} + {requestPluginId && ( + + )} )} diff --git a/src/pages/group_requests/pluginConfig.test.ts b/src/pages/group_requests/pluginConfig.test.ts new file mode 100644 index 00000000..88bc0f12 --- /dev/null +++ b/src/pages/group_requests/pluginConfig.test.ts @@ -0,0 +1,27 @@ +import {describe, it, expect} from 'vitest'; +import {pluginIdForApp, extractRequestedPluginData} from './pluginConfig'; + +describe('pluginIdForApp', () => { + it('returns the plugin id when the app has one', () => { + expect(pluginIdForApp({id: 'a1', app_group_lifecycle_plugin: 'test_plugin'})).toBe('test_plugin'); + }); + it('returns null when the app has no plugin', () => { + expect(pluginIdForApp({id: 'a1'})).toBeNull(); + expect(pluginIdForApp({id: 'a1', app_group_lifecycle_plugin: null})).toBeNull(); + }); + it('returns null when no app', () => { + expect(pluginIdForApp(null)).toBeNull(); + expect(pluginIdForApp(undefined)).toBeNull(); + }); +}); + +describe('extractRequestedPluginData', () => { + it('returns the plugin_data object from form values', () => { + const form = {plugin_data: {test_plugin: {configuration: {group_id: 'g1'}}}}; + expect(extractRequestedPluginData(form)).toEqual({test_plugin: {configuration: {group_id: 'g1'}}}); + }); + it('returns an empty object when absent', () => { + expect(extractRequestedPluginData({})).toEqual({}); + expect(extractRequestedPluginData({plugin_data: undefined})).toEqual({}); + }); +}); diff --git a/src/pages/group_requests/pluginConfig.ts b/src/pages/group_requests/pluginConfig.ts new file mode 100644 index 00000000..bd42782f --- /dev/null +++ b/src/pages/group_requests/pluginConfig.ts @@ -0,0 +1,21 @@ +/** + * Pure helpers for collecting app-group-lifecycle plugin config in the group + * request flow. Kept DOM-free so they can be unit-tested in isolation. + */ + +type AppLike = {id?: string | null; app_group_lifecycle_plugin?: string | null} | null | undefined; + +/** The lifecycle plugin id configured on an app, or null if none. */ +export function pluginIdForApp(app: AppLike): string | null { + return app?.app_group_lifecycle_plugin ?? null; +} + +/** + * Pull the nested `plugin_data` object that AppGroupLifecyclePluginConfigurationForm + * registers (field names like `plugin_data..configuration.`) out of + * react-hook-form values, defaulting to {} when no plugin section was rendered. + */ +export function extractRequestedPluginData(formValues: {plugin_data?: unknown}): Record { + const data = formValues?.plugin_data; + return data && typeof data === 'object' ? (data as Record) : {}; +} diff --git a/src/setupTests.ts b/src/setupTests.ts index 74b1a275..8f2609b7 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -2,4 +2,4 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom/extend-expect'; +import '@testing-library/jest-dom'; diff --git a/tests/test_group_request_plugin_config.py b/tests/test_group_request_plugin_config.py new file mode 100644 index 00000000..558a4e02 --- /dev/null +++ b/tests/test_group_request_plugin_config.py @@ -0,0 +1,322 @@ +from typing import Any, Callable, Generator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import TypeAdapter +from pytest_mock import MockerFixture + +from okta.models import Group as OktaSdkGroup + +from api.config import settings +from api.extensions import Db +from api.models import AccessRequestStatus, AppGroup, GroupRequest, OktaUser +from api.operations import ApproveGroupRequest, CreateGroupRequest +from api.schemas.requests_schemas import ( + CreateGroupRequestBody, + ResolveGroupRequestBody, +) +from api.services import okta +from tests.factories import AppFactory, OktaUserFactory +from tests.test_app_group_lifecycle_plugin import DummyPlugin + + +@pytest.fixture +def test_plugin(app: FastAPI, mocker: MockerFixture) -> Generator[DummyPlugin, None, None]: + """Register DummyPlugin (id 'test_plugin') as the app group lifecycle plugin.""" + import pluggy + + import api.plugins.app_group_lifecycle as plugin_module + from api.plugins.app_group_lifecycle import AppGroupLifecyclePluginSpec + + instance = DummyPlugin() + pm = pluggy.PluginManager("access_app_group_lifecycle") + pm.add_hookspecs(AppGroupLifecyclePluginSpec) + pm.register(plugin_module) + pm.register(instance, name=DummyPlugin.ID) + mocker.patch.object(plugin_module, "_cached_app_group_lifecycle_hook", pm.hook) + yield instance + plugin_module._cached_app_group_lifecycle_hook = None + + +def test_group_request_plugin_data_defaults_to_empty_dict(app: FastAPI, db: Db) -> None: + user: OktaUser = OktaUserFactory.create() + db.session.add(user) + db.session.commit() + + gr = GroupRequest( + id="reqplugindata0000001", + status=AccessRequestStatus.PENDING, + requester_user_id=user.id, + requested_group_name="Test Group", + requested_group_type="okta_group", + ) + db.session.add(gr) + db.session.commit() + db.session.refresh(gr) + + assert gr.requested_plugin_data == {} + assert gr.resolved_plugin_data == {} + + +def test_group_request_plugin_data_round_trips(app: FastAPI, db: Db) -> None: + user: OktaUser = OktaUserFactory.create() + db.session.add(user) + db.session.commit() + + payload = {"test_plugin": {"configuration": {"group_id": "g-123", "region": "us"}}} + gr = GroupRequest( + id="reqplugindata0000002", + status=AccessRequestStatus.PENDING, + requester_user_id=user.id, + requested_group_name="Test Group 2", + requested_group_type="app_group", + requested_plugin_data=payload, + ) + db.session.add(gr) + db.session.commit() + db.session.refresh(gr) + + assert gr.requested_plugin_data == payload + assert gr.resolved_plugin_data == {} + + +def test_app_group_body_accepts_requested_plugin_data() -> None: + adapter: TypeAdapter[Any] = TypeAdapter(CreateGroupRequestBody) + body = adapter.validate_python( + { + "requested_group_type": "app_group", + "requested_group_name": "App-Foo-Admins", + "requested_app_id": "app00000000000000001", + "requested_group_description": "Test description", + "requested_plugin_data": {"test_plugin": {"configuration": {"group_id": "g-1"}}}, + } + ) + assert body.requested_plugin_data == {"test_plugin": {"configuration": {"group_id": "g-1"}}} + + +def test_app_group_body_defaults_plugin_data_to_empty() -> None: + adapter: TypeAdapter[Any] = TypeAdapter(CreateGroupRequestBody) + body = adapter.validate_python( + { + "requested_group_type": "app_group", + "requested_group_name": "App-Foo-Admins", + "requested_app_id": "app00000000000000001", + "requested_group_description": "Test description", + } + ) + assert body.requested_plugin_data == {} + + +def test_resolve_body_accepts_resolved_plugin_data() -> None: + body = ResolveGroupRequestBody.model_validate( + {"approved": True, "resolved_plugin_data": {"test_plugin": {"configuration": {"group_id": "g-2"}}}} + ) + assert body.resolved_plugin_data == {"test_plugin": {"configuration": {"group_id": "g-2"}}} + + +def test_create_group_request_persists_requested_plugin_data(app: FastAPI, db: Db) -> None: + user: OktaUser = OktaUserFactory.create() + db.session.add(user) + db.session.commit() + app_obj = AppFactory.create() + db.session.add(app_obj) + db.session.commit() + + payload = {"test_plugin": {"configuration": {"group_id": "g-9"}}} + gr = CreateGroupRequest( + requester_user=user, + requested_group_name=f"App-{app_obj.name}-Admins", + requested_group_type="app_group", + requested_app_id=app_obj.id, + requested_plugin_data=payload, + ).execute() + + assert gr is not None + assert gr.requested_plugin_data == payload + + +def _make_app_with_plugin(db: Db) -> Any: + app_obj = AppFactory.create() + app_obj.app_group_lifecycle_plugin = DummyPlugin.ID + db.session.add(app_obj) + db.session.commit() + return app_obj + + +def test_post_app_group_request_rejects_missing_required_plugin_config( + app: FastAPI, + client: TestClient, + db: Db, + mock_user: Callable[[Any], None], + url_for: Callable[..., str], + test_plugin: DummyPlugin, +) -> None: + user: OktaUser = OktaUserFactory.create() + db.session.add(user) + db.session.commit() + mock_user(user) + app_obj = _make_app_with_plugin(db) + + resp = client.post( + url_for("api-group-requests.group_requests_create"), + json={ + "requested_group_type": "app_group", + "requested_group_name": f"App-{app_obj.name}-Admins", + "requested_group_description": "desc", + "requested_app_id": app_obj.id, + "requested_plugin_data": {DummyPlugin.ID: {"configuration": {}}}, + }, + ) + assert resp.status_code == 400 + assert "group_id" in resp.text + + +def test_post_app_group_request_accepts_valid_plugin_config( + app: FastAPI, + client: TestClient, + db: Db, + mock_user: Callable[[Any], None], + url_for: Callable[..., str], + test_plugin: DummyPlugin, +) -> None: + user: OktaUser = OktaUserFactory.create() + db.session.add(user) + db.session.commit() + mock_user(user) + app_obj = _make_app_with_plugin(db) + + payload = {DummyPlugin.ID: {"configuration": {"group_id": "g-77"}}} + resp = client.post( + url_for("api-group-requests.group_requests_create"), + json={ + "requested_group_type": "app_group", + "requested_group_name": f"App-{app_obj.name}-Admins", + "requested_group_description": "desc", + "requested_app_id": app_obj.id, + "requested_plugin_data": payload, + }, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["requested_plugin_data"] == payload + + +def test_approve_applies_resolved_plugin_data_over_requested( + app: FastAPI, + client: TestClient, + db: Db, + test_plugin: DummyPlugin, + mocker: MockerFixture, +) -> None: + admin = db.session.query(OktaUser).filter(OktaUser.email == settings.CURRENT_OKTA_USER_EMAIL).first() + mocker.patch.object( + okta, "create_group", side_effect=lambda name, desc: OktaSdkGroup({"id": "createdgrp0000000001"}) + ) + mocker.patch.object(okta, "async_add_user_to_group") + mocker.patch.object(okta, "async_add_owner_to_group") + + requester: OktaUser = OktaUserFactory.create() + db.session.add(requester) + db.session.commit() + app_obj = _make_app_with_plugin(db) + + gr = CreateGroupRequest( + requester_user=requester, + requested_group_name=f"App-{app_obj.name}-Admins", + requested_group_type="app_group", + requested_app_id=app_obj.id, + requested_plugin_data={DummyPlugin.ID: {"configuration": {"group_id": "from-request"}}}, + ).execute() + assert gr is not None + + gr.resolved_plugin_data = {DummyPlugin.ID: {"configuration": {"group_id": "from-resolver"}}} + db.session.commit() + + ApproveGroupRequest(group_request=gr, approver_user=admin, approval_reason="ok").execute() + + created = db.session.get(AppGroup, "createdgrp0000000001") + assert created is not None + assert created.plugin_data[DummyPlugin.ID]["configuration"]["group_id"] == "from-resolver" + assert "createdgrp0000000001" in test_plugin.group_created_calls + + +def test_approve_falls_back_to_requested_plugin_data( + app: FastAPI, + client: TestClient, + db: Db, + test_plugin: DummyPlugin, + mocker: MockerFixture, +) -> None: + admin = db.session.query(OktaUser).filter(OktaUser.email == settings.CURRENT_OKTA_USER_EMAIL).first() + mocker.patch.object( + okta, "create_group", side_effect=lambda name, desc: OktaSdkGroup({"id": "createdgrp0000000002"}) + ) + mocker.patch.object(okta, "async_add_user_to_group") + mocker.patch.object(okta, "async_add_owner_to_group") + + requester: OktaUser = OktaUserFactory.create() + db.session.add(requester) + db.session.commit() + app_obj = _make_app_with_plugin(db) + + gr = CreateGroupRequest( + requester_user=requester, + requested_group_name=f"App-{app_obj.name}-Admins", + requested_group_type="app_group", + requested_app_id=app_obj.id, + requested_plugin_data={DummyPlugin.ID: {"configuration": {"group_id": "only-requested"}}}, + ).execute() + assert gr is not None + + ApproveGroupRequest(group_request=gr, approver_user=admin, approval_reason="ok").execute() + + created = db.session.get(AppGroup, "createdgrp0000000002") + assert created is not None + assert created.plugin_data[DummyPlugin.ID]["configuration"]["group_id"] == "only-requested" + + +def test_put_group_request_persists_resolved_plugin_data( + app: FastAPI, + client: TestClient, + db: Db, + mock_user: Callable[[Any], None], + url_for: Callable[..., str], + test_plugin: DummyPlugin, + mocker: MockerFixture, +) -> None: + mocker.patch.object( + okta, "create_group", side_effect=lambda name, desc: OktaSdkGroup({"id": "createdgrp0000000003"}) + ) + mocker.patch.object(okta, "async_add_user_to_group") + mocker.patch.object(okta, "async_add_owner_to_group") + + # The seeded admin (email == CURRENT_OKTA_USER_EMAIL) is in access owners, so it + # passes both the router's is_access_admin check and the operation's own + # get_access_owners authorization without further mocking. + admin = db.session.query(OktaUser).filter(OktaUser.email == settings.CURRENT_OKTA_USER_EMAIL).first() + requester: OktaUser = OktaUserFactory.create() + db.session.add(requester) + db.session.commit() + app_obj = _make_app_with_plugin(db) + + gr = CreateGroupRequest( + requester_user=requester, + requested_group_name=f"App-{app_obj.name}-Admins", + requested_group_type="app_group", + requested_app_id=app_obj.id, + requested_plugin_data={DummyPlugin.ID: {"configuration": {"group_id": "req"}}}, + ).execute() + assert gr is not None + + mock_user(admin) + resp = client.put( + url_for("api-group-requests.group_request_by_id_put", group_request_id=gr.id), + json={ + "approved": True, + "resolved_plugin_data": {DummyPlugin.ID: {"configuration": {"group_id": "resolved"}}}, + }, + ) + assert resp.status_code == 200, resp.text + created = db.session.get(AppGroup, "createdgrp0000000003") + assert created is not None + assert created.plugin_data[DummyPlugin.ID]["configuration"]["group_id"] == "resolved"