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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions api/models/core_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="")

Expand All @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions api/operations/approve_group_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from api.extensions import db
from api.models import (
AccessRequestStatus,
App,
AppGroup,
GroupRequest,
OktaGroup,
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions api/operations/create_group_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions api/routers/group_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions api/schemas/requests_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[
Expand All @@ -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 -------------------------------------------------------------------
Expand Down
43 changes: 43 additions & 0 deletions migrations/versions/128a7f45cac6_group_request_plugin_data.py
Original file line number Diff line number Diff line change
@@ -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")
12 changes: 12 additions & 0 deletions src/api/apiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
19 changes: 19 additions & 0 deletions src/pages/group_requests/Create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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},
});
Expand Down Expand Up @@ -195,13 +208,16 @@ 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 ?? '',
requested_group_type: effectiveType,
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<typeof createRequest.mutate>[0]['body'];

if (body == null) {
Expand Down Expand Up @@ -374,6 +390,9 @@ function CreateRequestContainer(props: CreateRequestContainerProps) {
required
/>
</FormControl>
{requestPluginId && (
<AppGroupLifecyclePluginConfigurationForm entityType="group" selectedPluginId={requestPluginId} />
)}
</DialogContent>
<DialogActions>
<Button onClick={() => props.setOpen(false)}>Cancel</Button>
Expand Down
18 changes: 18 additions & 0 deletions src/pages/group_requests/Read.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -916,6 +925,15 @@ export default function ReadGroupRequest() {
</Grid>
) : null}
</Grid>
{requestPluginId && (
<AppGroupLifecyclePluginConfigurationForm
entityType="group"
selectedPluginId={requestPluginId}
currentConfig={
groupRequest.requested_plugin_data?.[requestPluginId]?.configuration ?? {}
}
/>
)}
<Divider sx={{my: 2}} />
</>
)}
Expand Down
27 changes: 27 additions & 0 deletions src/pages/group_requests/pluginConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
Loading