Skip to content

Commit 89952c0

Browse files
committed
fix(oauth2): enforce titles to be unique
CMK-29825 Change-Id: I3b93d1c2b6d5f830dabab6868682a584152cbaef
1 parent 2245080 commit 89952c0

File tree

3 files changed

+169
-7
lines changed

3 files changed

+169
-7
lines changed

cmk/gui/watolib/configuration_entity/_oauth2_connections.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from marshmallow import ValidationError
99

1010
from cmk.gui.form_specs import (
11+
FormSpecValidationError,
1112
get_visitor,
1213
process_validation_messages,
1314
RawDiskData,
@@ -25,9 +26,31 @@
2526
update_reference,
2627
)
2728
from cmk.gui.watolib.passwords import load_passwords
29+
from cmk.shared_typing import vue_formspec_components as shared_type_defs
2830
from cmk.utils.oauth2_connection import OAuth2Connection, OAuth2ConnectorType
2931

3032

33+
def _validate_unique_title(title: str, exclude_ident: str | None = None) -> None:
34+
"""Raise FormSpecValidationError if title is already used by another connection."""
35+
for ident, entry in load_oauth2_connections().items():
36+
if ident == exclude_ident:
37+
continue
38+
if entry["title"] == title:
39+
raise FormSpecValidationError(
40+
[
41+
shared_type_defs.ValidationMessage(
42+
location=["title"],
43+
message=_(
44+
"The title must be unique. The title '%s' is already used by "
45+
"the OAuth2 connection with ID %s."
46+
)
47+
% (title, ident),
48+
replacement_value=title,
49+
)
50+
]
51+
)
52+
53+
3154
def update_oauth2_connection_and_passwords_from_slidein_schema(
3255
data: RawFrontendData,
3356
connector_type: OAuth2ConnectorType,
@@ -45,6 +68,8 @@ def update_oauth2_connection_and_passwords_from_slidein_schema(
4568
disk_data = visitor.to_disk(data)
4669
assert isinstance(disk_data, dict)
4770

71+
_validate_unique_title(disk_data["title"], exclude_ident=disk_data["ident"])
72+
4873
owned_by = None
4974
match disk_data.get("editable_by"):
5075
case ("administrators", None):
@@ -106,6 +131,8 @@ def save_oauth2_connection_and_passwords_from_slidein_schema(
106131
field_name="data",
107132
)
108133

134+
_validate_unique_title(disk_data["title"])
135+
109136
owned_by = None
110137
match disk_data.get("editable_by"):
111138
case ("administrators", None):

packages/cmk-frontend-vue/src/mode-oauth2-connection/ModeCreateOAuth2ConnectionApp.vue

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ conditions defined in the file COPYING, which is part of this source code packag
77
<script setup lang="ts">
88
import { type Oauth2ConnectionConfig } from 'cmk-shared-typing/typescript/mode_oauth2_connection'
99
import type { FormSpec } from 'cmk-shared-typing/typescript/vue_formspec_components'
10-
import { provide, ref } from 'vue'
10+
import { computed, provide, ref } from 'vue'
1111
1212
import usei18n from '@/lib/i18n'
1313
import type { TranslatedString } from '@/lib/i18nString'
@@ -62,18 +62,21 @@ immediateWatch(
6262
)
6363
const dataRef = ref<OAuth2FormData>(props.form_spec.data)
6464
provide(submitKey, submit)
65+
66+
const initialData = props.form_spec.data
67+
const formSpecRef = computed(() => ({
68+
id: props.form_spec.id,
69+
spec: props.form_spec.spec,
70+
validation: validationRef.value,
71+
data: initialData
72+
}))
6573
</script>
6674

6775
<template>
6876
<CreateOAuth2Connection
6977
v-model:data="dataRef"
7078
:config="config"
71-
:form-spec="{
72-
id: form_spec.id,
73-
spec: form_spec.spec,
74-
validation: validationRef,
75-
data: dataRef
76-
}"
79+
:form-spec="formSpecRef"
7780
:authority-mapping="authority_mapping"
7881
:api="api"
7982
:connector-type="connector_type"

tests/unit/cmk/gui/openapi/configuration_entity/test_openapi_configuration_entity_oauth2_connection.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
MY_CLIENT_ID = str(uuid.uuid4())
1919
MY_TENANT_ID = str(uuid.uuid4())
2020

21+
SECOND_OAUTH2_CONNECTION_UUID = str(uuid.uuid4())
22+
SECOND_CLIENT_ID = str(uuid.uuid4())
23+
SECOND_TENANT_ID = str(uuid.uuid4())
24+
2125
OAUTH2_CONNECTION_CONTENT = {
2226
MY_OAUTH2_CONNECTION_UUID: OAuth2Connection(
2327
title="My OAuth2 connection",
@@ -31,6 +35,20 @@
3135
),
3236
}
3337

38+
TWO_OAUTH2_CONNECTIONS_CONTENT = {
39+
**OAUTH2_CONNECTION_CONTENT,
40+
SECOND_OAUTH2_CONNECTION_UUID: OAuth2Connection(
41+
title="Second OAuth2 connection",
42+
client_secret=("cmk_postprocessed", "stored_password", ("second_client_secret", "")),
43+
access_token=("cmk_postprocessed", "stored_password", ("second_access_token", "")),
44+
refresh_token=("cmk_postprocessed", "stored_password", ("second_refresh_token", "")),
45+
client_id=SECOND_CLIENT_ID,
46+
tenant_id=SECOND_TENANT_ID,
47+
authority="global",
48+
connector_type="microsoft_entra_id",
49+
),
50+
}
51+
3452

3553
@pytest.fixture
3654
def mock_update_passwords_merged_file(monkeypatch: pytest.MonkeyPatch) -> Iterable[None]:
@@ -231,3 +249,117 @@ def test_create_non_existing_oauth2_connection_without_permissions(
231249
# THEN
232250
assert resp.status_code == 401
233251
assert resp.json["title"] == "Unauthorized"
252+
253+
254+
@pytest.mark.usefixtures("mock_update_passwords_merged_file")
255+
def test_create_oauth2_connection_with_duplicate_title(
256+
clients: ClientRegistry, with_admin: tuple[str, str]
257+
) -> None:
258+
# GIVEN
259+
for ident, details in OAUTH2_CONNECTION_CONTENT.items():
260+
save_oauth2_connection(ident, details, user_id=None, pprint_value=False, use_git=False)
261+
clients.ConfigurationEntity.set_credentials(with_admin[0], with_admin[1])
262+
my_new_uuid = str(uuid.uuid4())
263+
264+
# WHEN
265+
resp = clients.ConfigurationEntity.create_configuration_entity(
266+
{
267+
"entity_type": ConfigEntityType.oauth2_connection.value,
268+
"entity_type_specifier": "microsoft_entra_id",
269+
"data": {
270+
"ident": my_new_uuid,
271+
"title": "My OAuth2 connection",
272+
"editable_by": ("administrators", None),
273+
"shared_with": [],
274+
"client_secret": ("explicit_password", "", "my_client_secret", False),
275+
"access_token": ("explicit_password", "", "my_access_token", False),
276+
"refresh_token": ("explicit_password", "", "my_refresh_token", False),
277+
"client_id": MY_CLIENT_ID,
278+
"tenant_id": MY_TENANT_ID,
279+
"authority": option_id("global"),
280+
},
281+
},
282+
expect_ok=False,
283+
)
284+
285+
# THEN
286+
assert resp.status_code == 422, resp.json
287+
validation_errors = resp.json["ext"]["validation_errors"]
288+
assert len(validation_errors) == 1
289+
assert validation_errors[0]["location"] == ["title"]
290+
assert "The title must be unique" in validation_errors[0]["message"]
291+
292+
293+
@pytest.mark.usefixtures("mock_update_passwords_merged_file")
294+
def test_update_oauth2_connection_keeping_same_title(
295+
clients: ClientRegistry, with_admin: tuple[str, str]
296+
) -> None:
297+
# GIVEN
298+
for ident, details in OAUTH2_CONNECTION_CONTENT.items():
299+
save_oauth2_connection(ident, details, user_id=None, pprint_value=False, use_git=False)
300+
clients.ConfigurationEntity.set_credentials(with_admin[0], with_admin[1])
301+
302+
# WHEN
303+
resp = clients.ConfigurationEntity.update_configuration_entity(
304+
{
305+
"entity_id": MY_OAUTH2_CONNECTION_UUID,
306+
"entity_type": ConfigEntityType.oauth2_connection.value,
307+
"entity_type_specifier": "microsoft_entra_id",
308+
"data": {
309+
"ident": MY_OAUTH2_CONNECTION_UUID,
310+
"title": "My OAuth2 connection",
311+
"editable_by": ("administrators", None),
312+
"shared_with": [],
313+
"client_secret": ("explicit_password", "", "my_client_secret", False),
314+
"access_token": ("explicit_password", "", "my_access_token", False),
315+
"refresh_token": ("explicit_password", "", "my_refresh_token", False),
316+
"client_id": MY_CLIENT_ID,
317+
"tenant_id": MY_TENANT_ID,
318+
"authority": option_id("global"),
319+
},
320+
},
321+
expect_ok=True,
322+
)
323+
324+
# THEN
325+
assert resp.status_code == 200, resp.json
326+
assert resp.json["id"] == MY_OAUTH2_CONNECTION_UUID, resp.json
327+
328+
329+
@pytest.mark.usefixtures("mock_update_passwords_merged_file")
330+
def test_update_oauth2_connection_to_existing_title(
331+
clients: ClientRegistry, with_admin: tuple[str, str]
332+
) -> None:
333+
# GIVEN
334+
for ident, details in TWO_OAUTH2_CONNECTIONS_CONTENT.items():
335+
save_oauth2_connection(ident, details, user_id=None, pprint_value=False, use_git=False)
336+
clients.ConfigurationEntity.set_credentials(with_admin[0], with_admin[1])
337+
338+
# WHEN
339+
resp = clients.ConfigurationEntity.update_configuration_entity(
340+
{
341+
"entity_id": MY_OAUTH2_CONNECTION_UUID,
342+
"entity_type": ConfigEntityType.oauth2_connection.value,
343+
"entity_type_specifier": "microsoft_entra_id",
344+
"data": {
345+
"ident": MY_OAUTH2_CONNECTION_UUID,
346+
"title": "Second OAuth2 connection",
347+
"editable_by": ("administrators", None),
348+
"shared_with": [],
349+
"client_secret": ("explicit_password", "", "my_client_secret", False),
350+
"access_token": ("explicit_password", "", "my_access_token", False),
351+
"refresh_token": ("explicit_password", "", "my_refresh_token", False),
352+
"client_id": MY_CLIENT_ID,
353+
"tenant_id": MY_TENANT_ID,
354+
"authority": option_id("global"),
355+
},
356+
},
357+
expect_ok=False,
358+
)
359+
360+
# THEN
361+
assert resp.status_code == 422, resp.json
362+
validation_errors = resp.json["ext"]["validation_errors"]
363+
assert len(validation_errors) == 1
364+
assert validation_errors[0]["location"] == ["title"]
365+
assert "The title must be unique" in validation_errors[0]["message"]

0 commit comments

Comments
 (0)