Skip to content
Closed
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
52 changes: 52 additions & 0 deletions src/okta_mcp_server/tools/policies/policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,58 @@
from okta_mcp_server.utils.serialization import json_response, none_body_error
from okta_mcp_server.utils.validation import validate_ids

# Workaround for an SDK model gap: Okta's API returns `_embedded` on a Policy resource
# as a flat map whose values aren't always nested objects — e.g. an ACCESS_POLICY that is
# mapped to an app returns `_embedded: {"resourceType": "APP"}`, a plain string value. The
# generated `Policy.embedded` field is typed `Dict[str, Dict[str, Any]]`, which requires
# every value to itself be a dict, so this raises a ValidationError and aborts
# `list_policies(type="ACCESS_POLICY")` and `get_policy` entirely, for every policy in the
# tenant, any time at least one carries this kind of `_embedded` entry.
# Fix: relax the annotation to Dict[str, Any] and force a Pydantic schema rebuild on the
# base class and on AccessPolicy explicitly (the subclass this was actually observed on).
# Pydantic v2 subclasses build their own core schema at class-definition time, so a
# subclass whose module happened to already be imported elsewhere before this patch runs
# would otherwise keep its stale, stricter schema even after the parent is rebuilt; we also
# sweep any other Policy subclass already loaded at patch time so this isn't order-sensitive.
try:
import typing as _typing
from okta.models.policy import Policy as _Policy
from okta.models.access_policy import AccessPolicy as _AccessPolicy

_embedded_patched_type = _typing.Optional[_typing.Dict[str, _typing.Any]]
_policy_classes = {_Policy, _AccessPolicy, *_Policy.__subclasses__()}
for _cls in _policy_classes:
_cls.__annotations__["embedded"] = _embedded_patched_type
if "embedded" in _cls.model_fields:
_cls.model_fields["embedded"].annotation = _embedded_patched_type
_cls.model_rebuild(force=True)
logger.debug("Applied Policy._embedded type workaround (flat non-dict _embedded values)")
except Exception as _patch_err:
logger.warning(f"Could not apply Policy._embedded workaround: {_patch_err}")

# Workaround for an SDK enum gap: `AuthenticatorEnrollmentPolicyAuthenticatorType` (used by
# MFA_ENROLL policy authenticator settings) does not include `smart_card_idp`, even though
# the SDK's own `AuthenticatorKeyEnum` recognizes it as a valid authenticator key elsewhere.
# Smart-card / PIV-CAC authenticators are common in Okta for Government tenants and rare in
# commercial Okta, which is presumably why this enum member was missed. Without this fix,
# any MFA_ENROLL policy referencing a smart-card authenticator makes
# `list_policies(type="MFA_ENROLL")` raise for the whole page.
# Fix: relax the `key` field to a plain string so any authenticator key the API returns is
# accepted, instead of maintaining a second, hand-kept enum that can drift from the API.
try:
from okta.models.authenticator_enrollment_policy_authenticator_settings import (
AuthenticatorEnrollmentPolicyAuthenticatorSettings as _AuthenticatorEnrollmentPolicyAuthenticatorSettings,
)

_key_patched_type = _typing.Optional[str]
_AuthenticatorEnrollmentPolicyAuthenticatorSettings.__annotations__["key"] = _key_patched_type
if "key" in _AuthenticatorEnrollmentPolicyAuthenticatorSettings.model_fields:
_AuthenticatorEnrollmentPolicyAuthenticatorSettings.model_fields["key"].annotation = _key_patched_type
_AuthenticatorEnrollmentPolicyAuthenticatorSettings.model_rebuild(force=True)
logger.debug("Applied MFA_ENROLL authenticator 'key' type workaround (missing smart_card_idp enum member)")
except Exception as _patch_err:
logger.warning(f"Could not apply MFA_ENROLL authenticator 'key' workaround: {_patch_err}")


# Mapping from Okta policy rule type → typed SDK model class.
# The base PolicyRule model silently drops type-specific fields like `actions` and
Expand Down
96 changes: 96 additions & 0 deletions tests/test_policy_model_workarounds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# The Okta software accompanied by this notice is provided pursuant to the following terms:
# Copyright © 2026-Present, Okta, Inc.
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and limitations under the License.

"""Regression tests for the SDK model workarounds applied on import of
``okta_mcp_server.tools.policies.policies``.

Both bugs were observed against a live Okta for Government tenant and reproduce on
every request, not just as flakes: a single ACCESS_POLICY or MFA_ENROLL policy of
the affected shape poisons the entire `list_policies` page.
"""

from __future__ import annotations

from okta.models.access_policy import AccessPolicy
from okta.models.authenticator_enrollment_policy_authenticator_settings import (
AuthenticatorEnrollmentPolicyAuthenticatorSettings,
)

# Importing the tools module applies both workarounds as an import-time side effect,
# exactly like the existing LogSecurityContext workaround in system_logs.py.
import okta_mcp_server.tools.policies.policies # noqa: F401


class TestAccessPolicyEmbeddedWorkaround:
"""`_embedded` on an ACCESS_POLICY mapped to an app is a flat string value,
not a nested dict — e.g. ``{"resourceType": "APP"}``."""

def test_flat_string_value_no_longer_raises(self):
policy = AccessPolicy.from_dict(
{
"id": "rst1abcdefghij0000",
"name": "Test Access Policy",
"status": "ACTIVE",
"type": "ACCESS_POLICY",
"_embedded": {"resourceType": "APP"},
}
)
assert policy.embedded == {"resourceType": "APP"}

def test_nested_dict_values_still_supported(self):
"""Backward compatibility: `_embedded` entries that ARE nested objects
(the shape the original, stricter annotation assumed) must still work."""
policy = AccessPolicy.from_dict(
{
"id": "rst1abcdefghij0001",
"name": "Test Access Policy",
"status": "ACTIVE",
"type": "ACCESS_POLICY",
"_embedded": {"someResource": {"id": "abc123"}},
}
)
assert policy.embedded == {"someResource": {"id": "abc123"}}

def test_no_embedded_field_still_supported(self):
policy = AccessPolicy.from_dict(
{
"id": "rst1abcdefghij0002",
"name": "Test Access Policy",
"status": "ACTIVE",
"type": "ACCESS_POLICY",
}
)
assert policy.embedded is None


class TestAuthenticatorEnrollmentSmartCardIdpWorkaround:
"""`AuthenticatorEnrollmentPolicyAuthenticatorType` doesn't include
`smart_card_idp`, even though the SDK's own `AuthenticatorKeyEnum` does —
common on Okta for Government tenants that enable PIV/CAC authentication."""

def test_smart_card_idp_no_longer_raises(self):
settings = AuthenticatorEnrollmentPolicyAuthenticatorSettings.from_dict(
{"key": "smart_card_idp"}
)
assert settings.key == "smart_card_idp"

def test_previously_valid_key_still_supported(self):
"""Backward compatibility: keys that were already valid enum members
must continue to validate identically after the relaxation."""
settings = AuthenticatorEnrollmentPolicyAuthenticatorSettings.from_dict(
{"key": "okta_verify"}
)
assert settings.key == "okta_verify"

def test_unknown_junk_key_is_also_accepted(self):
"""Documents the accepted tradeoff of relaxing `key` to `str`: any string
Okta returns is now passed through rather than raising, in exchange for
no longer silently dropping the whole page on an unrecognized authenticator."""
settings = AuthenticatorEnrollmentPolicyAuthenticatorSettings.from_dict(
{"key": "not_a_real_authenticator_key"}
)
assert settings.key == "not_a_real_authenticator_key"