From a612f967c788c16a3621261bb67a558a33952493 Mon Sep 17 00:00:00 2001 From: Peter Collins Date: Thu, 16 Jul 2026 16:24:35 -0700 Subject: [PATCH 1/3] feat(group-requests): store plugin group config on GroupRequest Add two JSON columns to GroupRequest -- requested_plugin_data (supplied with the request) and resolved_plugin_data (a resolver-editable copy) -- mirroring the existing requested_*/resolved_* pairs, plus the Alembic migration. Config is stored in the same shape as a group's plugin_data ({plugin_id: {configuration: {...}}}); no status is kept, since the lifecycle hook populates that after the group is created. Surface both on GroupRequestDetail, requested_plugin_data on the app-group create body, and resolved_plugin_data on the resolve body. Co-Authored-By: Claude Opus 4.8 --- api/models/core_models.py | 23 ++++++++++ api/schemas/requests_schemas.py | 4 ++ .../128a7f45cac6_group_request_plugin_data.py | 43 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 migrations/versions/128a7f45cac6_group_request_plugin_data.py diff --git a/api/models/core_models.py b/api/models/core_models.py index abcc6e6a..bba90173 100644 --- a/api/models/core_models.py +++ b/api/models/core_models.py @@ -976,6 +976,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="") @@ -997,6 +1009,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/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..22360275 --- /dev/null +++ b/migrations/versions/128a7f45cac6_group_request_plugin_data.py @@ -0,0 +1,43 @@ +"""group_request plugin_data + +Revision ID: 128a7f45cac6 +Revises: 98bc5533e0f9 +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 = "98bc5533e0f9" +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") From 0ce2073b3ea9d1dd7b9b29164a0b6234276bdc88 Mon Sep 17 00:00:00 2001 From: Peter Collins Date: Thu, 16 Jul 2026 16:24:35 -0700 Subject: [PATCH 2/3] feat(group-requests): validate, persist, and apply plugin group config Let a requester supply app-group-lifecycle plugin config when requesting an app group, and a resolver edit it before approval, so the group created on approval is configured and the plugin's group_created hook runs against it: - CreateGroupRequest persists requested_plugin_data; the POST router validates it against the app's plugin (400 on invalid/missing-required). - ApproveGroupRequest resolves resolved_plugin_data or requested_plugin_data, re-validates it defensively (the app/config may have changed since filing), and sets it on the new AppGroup *before* CreateGroup runs so group_created sees the configured group. - The resolve (PUT) handler copies resolved_plugin_data so a resolver can edit before approving. Co-Authored-By: Claude Opus 4.8 --- api/operations/approve_group_request.py | 24 ++ api/operations/create_group_request.py | 3 + api/routers/group_requests.py | 19 ++ tests/test_group_request_plugin_config.py | 329 ++++++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 tests/test_group_request_plugin_config.py diff --git a/api/operations/approve_group_request.py b/api/operations/approve_group_request.py index 625a9d4a..00229d52 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, @@ -117,6 +118,11 @@ async 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 await get_access_owners()} @@ -205,6 +211,24 @@ async 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 = await 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 564117d9..c29f4bc4 100644 --- a/api/operations/create_group_request.py +++ b/api/operations/create_group_request.py @@ -39,6 +39,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() @@ -51,6 +52,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 {} async def execute(self) -> Optional[GroupRequest]: requester = await db.session.get(OktaUser, self.requester_user_id) @@ -126,6 +128,7 @@ async 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 0ae65d79..b6e45013 100644 --- a/api/routers/group_requests.py +++ b/api/routers/group_requests.py @@ -182,6 +182,22 @@ async 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 = ( @@ -219,6 +235,7 @@ async 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") @@ -286,6 +303,8 @@ async 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 await db.commit() diff --git a/tests/test_group_request_plugin_config.py b/tests/test_group_request_plugin_config.py new file mode 100644 index 00000000..51e8ddf5 --- /dev/null +++ b/tests/test_group_request_plugin_config.py @@ -0,0 +1,329 @@ +from typing import Any, Callable, Generator + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient +from pydantic import TypeAdapter +from pytest_mock import MockerFixture +from sqlalchemy import select + +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 + + +async def test_group_request_plugin_data_defaults_to_empty_dict(app: FastAPI, db: Db) -> None: + user: OktaUser = OktaUserFactory.build() + db.session.add(user) + await 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) + await db.session.commit() + await db.session.refresh(gr) + + assert gr.requested_plugin_data == {} + assert gr.resolved_plugin_data == {} + + +async def test_group_request_plugin_data_round_trips(app: FastAPI, db: Db) -> None: + user: OktaUser = OktaUserFactory.build() + db.session.add(user) + await 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) + await db.session.commit() + await 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"}}} + + +async def test_create_group_request_persists_requested_plugin_data(app: FastAPI, db: Db) -> None: + user: OktaUser = OktaUserFactory.build() + db.session.add(user) + await db.session.commit() + app_obj = AppFactory.build() + db.session.add(app_obj) + await db.session.commit() + + payload = {"test_plugin": {"configuration": {"group_id": "g-9"}}} + gr = await 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 + + +async def _make_app_with_plugin(db: Db) -> Any: + app_obj = AppFactory.build() + app_obj.app_group_lifecycle_plugin = DummyPlugin.ID + db.session.add(app_obj) + await db.session.commit() + return app_obj + + +async def test_post_app_group_request_rejects_missing_required_plugin_config( + app: FastAPI, + client: AsyncClient, + db: Db, + mock_user: Callable[[Any], None], + url_for: Callable[..., str], + test_plugin: DummyPlugin, +) -> None: + user: OktaUser = OktaUserFactory.build() + db.session.add(user) + await db.session.commit() + mock_user(user) + app_obj = await _make_app_with_plugin(db) + + resp = await 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 + + +async def test_post_app_group_request_accepts_valid_plugin_config( + app: FastAPI, + client: AsyncClient, + db: Db, + mock_user: Callable[[Any], None], + url_for: Callable[..., str], + test_plugin: DummyPlugin, +) -> None: + user: OktaUser = OktaUserFactory.build() + db.session.add(user) + await db.session.commit() + mock_user(user) + app_obj = await _make_app_with_plugin(db) + + payload = {DummyPlugin.ID: {"configuration": {"group_id": "g-77"}}} + resp = await 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 + + +async def test_approve_applies_resolved_plugin_data_over_requested( + app: FastAPI, + client: AsyncClient, + db: Db, + test_plugin: DummyPlugin, + mocker: MockerFixture, +) -> None: + admin = ( + await db.session.scalars(select(OktaUser).where(OktaUser.email == settings.CURRENT_OKTA_USER_EMAIL)) + ).first() + mocker.patch.object( + okta, "create_group", side_effect=lambda name, desc: OktaSdkGroup.from_dict({"id": "createdgrp0000000001"}) + ) + mocker.patch.object(okta, "add_user_to_group") + mocker.patch.object(okta, "add_owner_to_group") + + requester: OktaUser = OktaUserFactory.build() + db.session.add(requester) + await db.session.commit() + app_obj = await _make_app_with_plugin(db) + + gr = await 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"}}} + await db.session.commit() + + await ApproveGroupRequest(group_request=gr, approver_user=admin, approval_reason="ok").execute() + + created = await 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 + + +async def test_approve_falls_back_to_requested_plugin_data( + app: FastAPI, + client: AsyncClient, + db: Db, + test_plugin: DummyPlugin, + mocker: MockerFixture, +) -> None: + admin = ( + await db.session.scalars(select(OktaUser).where(OktaUser.email == settings.CURRENT_OKTA_USER_EMAIL)) + ).first() + mocker.patch.object( + okta, "create_group", side_effect=lambda name, desc: OktaSdkGroup.from_dict({"id": "createdgrp0000000002"}) + ) + mocker.patch.object(okta, "add_user_to_group") + mocker.patch.object(okta, "add_owner_to_group") + + requester: OktaUser = OktaUserFactory.build() + db.session.add(requester) + await db.session.commit() + app_obj = await _make_app_with_plugin(db) + + gr = await 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 + + await ApproveGroupRequest(group_request=gr, approver_user=admin, approval_reason="ok").execute() + + created = await db.session.get(AppGroup, "createdgrp0000000002") + assert created is not None + assert created.plugin_data[DummyPlugin.ID]["configuration"]["group_id"] == "only-requested" + + +async def test_put_group_request_persists_resolved_plugin_data( + app: FastAPI, + client: AsyncClient, + 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.from_dict({"id": "createdgrp0000000003"}) + ) + mocker.patch.object(okta, "add_user_to_group") + mocker.patch.object(okta, "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 = ( + await db.session.scalars(select(OktaUser).where(OktaUser.email == settings.CURRENT_OKTA_USER_EMAIL)) + ).first() + requester: OktaUser = OktaUserFactory.build() + db.session.add(requester) + await db.session.commit() + app_obj = await _make_app_with_plugin(db) + + gr = await 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 = await 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 = await db.session.get(AppGroup, "createdgrp0000000003") + assert created is not None + assert created.plugin_data[DummyPlugin.ID]["configuration"]["group_id"] == "resolved" From dccab800c1202b324119d4f4a9164f6ee6c841c2 Mon Sep 17 00:00:00 2001 From: Peter Collins Date: Thu, 16 Jul 2026 16:24:35 -0700 Subject: [PATCH 3/3] feat(ui): collect and edit plugin group config in group requests Render the existing AppGroupLifecyclePluginConfigurationForm for app-group requests whose target app has a lifecycle plugin: the create dialog submits requested_plugin_data, and the resolver's read view pre-fills with the requester's config and submits resolved_plugin_data. Small DOM-free helpers (pluginConfig.ts) with unit tests. Regenerate the API client for the new request plugin_data fields. Also fix a pre-existing jest-dom v6 import in setupTests.ts (the /extend-expect subpath was dropped) so the frontend test suite runs. Co-Authored-By: Claude Opus 4.8 --- src/api/apiSchemas.ts | 12 +++++++++ src/pages/group_requests/Create.tsx | 19 +++++++++++++ src/pages/group_requests/Read.tsx | 18 +++++++++++++ src/pages/group_requests/pluginConfig.test.ts | 27 +++++++++++++++++++ src/pages/group_requests/pluginConfig.ts | 21 +++++++++++++++ src/setupTests.ts | 2 +- 6 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/pages/group_requests/pluginConfig.test.ts create mode 100644 src/pages/group_requests/pluginConfig.ts 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';