Skip to content

Commit 178e044

Browse files
authored
Implement OIDC login (#238)
Use /login endpoint instead of basic login. The returned sessionID is then sent in subsequent requests. The /login endpoint can be used with either local or OIDC users.
1 parent 2211566 commit 178e044

File tree

12 files changed

+230
-60
lines changed

12 files changed

+230
-60
lines changed

.github/actions/make-integ-config/action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ inputs:
1919
oidc_client_secret:
2020
description: 'oidc_client_secret'
2121
required: true
22+
oidc_users_0_password:
23+
description: 'oidc_users_0_password'
24+
required: true
2225
runs:
2326
using: "composite"
2427
steps:
@@ -35,6 +38,8 @@ runs:
3538
smb_share: /cidata
3639
oidc_client_id: d2298ec0-0596-49d2-9554-840a2fe20603
3740
oidc_client_secret: ${{ inputs.oidc_client_secret }}
41+
oidc_users_0_username: xlabciuser@scalemsdnscalecomputing.onmicrosoft.com
42+
oidc_users_0_password: ${{ inputs.oidc_users_0_password }}
3843
EOF
3944
cat integ_config_vars.yml | jinja2 --strict tests/integration/integration_config.yml.j2 > tests/integration/integration_config.yml
4045
echo "sc_host: ${{ inputs.sc_host }}" >> tests/integration/integration_config.yml

.github/workflows/integ-test.yml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ on:
1414
# dns_config|time_server - NTP cannot be reconfigured if DNS is invalid
1515
# git_issues - slow, do not run on each push. TODO - run them only once a day
1616
# oidc_config - during reconfiguration API returns 500/502 errors for other requests
17+
# utils_login - it uses OIDC user to login
1718
# smtp - email_alert test requires a configured SMTP
1819
# role_cluster_config - role cluster_config reconfigures DNS, SMTP, OIDC. And it is slow.
1920
# version_update_single_node - role would change version, VSNS system cannot be updated.
@@ -23,7 +24,7 @@ on:
2324
List integration tests to exclude.
2425
Use "*" to exclude all tests.
2526
Use regex like 'node|^git_issue|^dns_config$' to exclude only a subset.
26-
default: "^dns_config$|^cluster_shutdown$|^version_update$|^oidc_config$|^smtp$|^role_cluster_config$|^role_version_update_single_node$"
27+
default: "^dns_config$|^cluster_shutdown$|^version_update$|^oidc_config$|^smtp$|^role_cluster_config$|^role_version_update_single_node$|^utils_login$"
2728
examples_tests_include:
2829
type: string
2930
description: |-
@@ -32,7 +33,7 @@ on:
3233
default: "iso_info"
3334
env:
3435
INTEG_TESTS_INCLUDE_SCHEDULE: "*"
35-
INTEG_TESTS_EXCLUDE_SCHEDULE: "^dns_config$|^cluster_shutdown$|^version_update$|^oidc_config$|^smtp$|^role_cluster_config$|^role_version_update_single_node$"
36+
INTEG_TESTS_EXCLUDE_SCHEDULE: "^dns_config$|^cluster_shutdown$|^version_update$|^oidc_config$|^smtp$|^role_cluster_config$|^role_version_update_single_node$|^utils_login$"
3637
EXAMPLES_TESTS_INCLUDE_SCHEDULE: "*"
3738
# ansible-test needs special directory structure.
3839
# WORKDIR is a subdir of GITHUB_WORKSPACE
@@ -73,6 +74,7 @@ jobs:
7374
sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }}
7475
smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }}
7576
oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }}
77+
oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }}
7678
working_directory: ${{ env.WORKDIR }}
7779
- run: ansible-playbook tests/integration/prepare/prepare_iso.yml
7880
- run: ansible-playbook tests/integration/prepare/prepare_vm.yml
@@ -177,6 +179,7 @@ jobs:
177179
sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }}
178180
smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }}
179181
oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }}
182+
oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }}
180183
working_directory: ${{ env.WORKDIR }}
181184
- run: |
182185
pwd
@@ -220,6 +223,12 @@ jobs:
220223
test_name: support_tunnel
221224
- sc_host: https://10.5.11.50
222225
test_name: vm_clone__replicated
226+
# test oidc login
227+
- sc_host: https://10.5.11.50
228+
test_name: utils_login
229+
# test inventory plugin with oidc login
230+
- sc_host: https://10.5.11.50
231+
test_name: inventory
223232
exclude:
224233
# The VSNS were not configured with remote replication cluster.
225234
- sc_host: https://10.5.11.200
@@ -255,6 +264,7 @@ jobs:
255264
sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }}
256265
smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }}
257266
oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }}
267+
oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }}
258268
working_directory: ${{ env.WORKDIR }}
259269
- run: ansible-test integration --local ${{ matrix.test_name }}
260270

@@ -282,6 +292,7 @@ jobs:
282292
sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }}
283293
smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }}
284294
oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }}
295+
oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }}
285296
working_directory: ${{ env.WORKDIR }}
286297
- run: ansible-galaxy collection install community.general
287298
- run: ansible-playbook tests/integration/cleanup/ci_replica_cleanup.yml
@@ -306,6 +317,7 @@ jobs:
306317
sc_password_50: ${{ secrets.CI_CONFIG_HC_IP50_SC_PASSWORD }}
307318
smb_password: ${{ secrets.CI_CONFIG_HC_IP50_SMB_PASSWORD }}
308319
oidc_client_secret: ${{ secrets.OIDC_CLIENT_SECRET }}
320+
oidc_users_0_password: ${{ secrets.OIDC_USERS_0_PASSWORD }}
309321
working_directory: ${{ env.WORKDIR }}
310322
- run: |
311323
cd tests/integration/cleanup && ./smb_cleanup.sh \

plugins/doc_fragments/cluster_instance.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,14 @@ class ModuleDocFragment(object):
4747
variable will be used.
4848
required: false
4949
type: float
50+
auth_method:
51+
description:
52+
- Select login method.
53+
If not set, the value of the C(SC_AUTH_METHOD) environment
54+
variable will be used.
55+
- Value I(local) - username/password is verified by the HyperCore server (the local users).
56+
- Value I(oidc) - username/password is verified by the configured OIDC provider.
57+
default: local
58+
choices: [local, oidc]
59+
type: str
5060
"""

plugins/inventory/hypercore.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ def parse(self, inventory, loader, path, cache=False):
223223
username = os.getenv("SC_USERNAME")
224224
password = os.getenv("SC_PASSWORD")
225225
timeout = os.getenv("SC_TIMEOUT")
226+
auth_method = os.getenv("SC_AUTH_METHOD")
226227
if timeout:
227228
try:
228229
timeout = float(timeout)
@@ -234,7 +235,7 @@ def parse(self, inventory, loader, path, cache=False):
234235
raise errors.ScaleComputingError(
235236
"Missing one or more parameters: sc_host, sc_username, sc_password."
236237
)
237-
client = Client(host, username, password, timeout)
238+
client = Client(host, username, password, timeout, auth_method)
238239
rest_client = RestClient(client)
239240
vms = rest_client.list_records("/rest/v1/VirDomain")
240241

plugins/module_utils/arguments.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from ansible.module_utils.basic import env_fallback
1212
from typing import Any
13+
from ..module_utils.client import AuthMethod
1314

1415
# TODO - env from /etc/environment is loaded
1516
# But when env is set in bash session, env seems to be lost on ssh connection to localhost.
@@ -42,6 +43,12 @@
4243
required=False,
4344
fallback=(env_fallback, ["SC_TIMEOUT"]),
4445
),
46+
auth_method=dict(
47+
type="str",
48+
default=AuthMethod.local,
49+
choices=[am.value for am in AuthMethod],
50+
fallback=(env_fallback, ["SC_AUTH_METHOD"]),
51+
),
4552
),
4653
required_together=[("username", "password")],
4754
),

plugins/module_utils/client.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
import ssl
1313
from typing import Any, Optional, Union
1414
from io import BufferedReader
15+
import enum
1516

16-
from ansible.module_utils.urls import Request, basic_auth_header
17+
from ansible.module_utils.urls import Request
1718

1819
from .errors import (
1920
AuthError,
@@ -29,6 +30,11 @@
2930
DEFAULT_HEADERS = dict(Accept="application/json")
3031

3132

33+
class AuthMethod(str, enum.Enum):
34+
local = "local"
35+
oidc = "oidc"
36+
37+
3238
class Response:
3339
# I have felling (but I'm not sure) we will always use
3440
# "Response(raw_resp.status, raw_resp.read(), raw_resp.headers)"
@@ -64,6 +70,7 @@ def __init__(
6470
username: str,
6571
password: str,
6672
timeout: float,
73+
auth_method: str,
6774
):
6875
if not (host or "").startswith(("https://", "http://")):
6976
raise ScaleComputingError(
@@ -75,8 +82,9 @@ def __init__(
7582
self.username = username
7683
self.password = password
7784
self.timeout = timeout
85+
self.auth_method = auth_method
7886

79-
self._auth_header: Optional[dict[str, bytes]] = None
87+
self._auth_header: Optional[dict[str, str]] = None
8088
self._client = Request()
8189

8290
@classmethod
@@ -86,19 +94,38 @@ def get_client(cls, cluster_instance: TypedClusterInstance) -> Client:
8694
cluster_instance["username"],
8795
cluster_instance["password"],
8896
cluster_instance["timeout"],
97+
cluster_instance["auth_method"],
8998
)
9099

91100
@property
92-
def auth_header(self) -> dict[str, bytes]:
101+
def auth_header(self) -> dict[str, str]:
93102
if not self._auth_header:
94103
self._auth_header = self._login()
95104
return self._auth_header
96105

97-
def _login(self) -> dict[str, bytes]:
106+
def _login(self) -> dict[str, str]:
98107
return self._login_username_password()
99108

100-
def _login_username_password(self) -> dict[str, bytes]:
101-
return dict(Authorization=basic_auth_header(self.username, self.password))
109+
def _login_username_password(self) -> dict[str, str]:
110+
headers = {
111+
"Accept": "application/json",
112+
"Content-type": "application/json",
113+
}
114+
use_oidc = self.auth_method == AuthMethod.oidc.value
115+
resp = self._request(
116+
"POST",
117+
f"{self.host}/rest/v1/login",
118+
data=json.dumps(
119+
dict(
120+
username=self.username,
121+
password=self.password,
122+
useOIDC=use_oidc,
123+
)
124+
),
125+
headers=headers,
126+
timeout=self.timeout,
127+
)
128+
return dict(Cookie=f"sessionID={resp.json['sessionID']}")
102129

103130
def _request(
104131
self,
@@ -108,9 +135,8 @@ def _request(
108135
headers: Optional[dict[Any, Any]] = None,
109136
timeout: Optional[float] = None,
110137
) -> Response:
111-
if (
112-
timeout is None
113-
): # If timeout from request is not specifically provided, take it from the Client.
138+
if timeout is None:
139+
# If timeout from request is not specifically provided, take it from the Client.
114140
timeout = self.timeout
115141
try:
116142
raw_resp = self._client.open(

plugins/module_utils/typed_classes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class TypedClusterInstance(TypedDict):
2121
username: str
2222
password: str
2323
timeout: float
24+
auth_method: str
2425

2526

2627
# Registration to ansible return dict.

tests/integration/integration_config.yml.j2

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ sc_config:
5555
shared_secret_default: "{{ oidc_client_secret }}"
5656
config_url_default: https://login.microsoftonline.com/76d4c62a-a9ca-4dc2-9187-e2cc4d9abe7f/v2.0/.well-known/openid-configuration
5757
scopes: "openid+profile"
58+
# Users that can login when config_url_default is configured as OIDC provider
59+
users:
60+
- username: "{{ oidc_users_0_username }}"
61+
password: "{{ oidc_users_0_password }}"
5862
client_id_test: ci-client-id
5963
shared_secret_test: ci-client-secret
6064
config_url_test: https://login.microsoftonline.com/76d4c62a-a9ca-4dc2-9187-e2cc4d9abe7f/v2.0/.well-known/openid-configuration

tests/integration/targets/inventory/runme.sh

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ print("export SC_HOST='{}'".format(sc_host))
1212
print("export SC_TIMEOUT='{}'".format(sc_timeout))
1313
print("export SC_USERNAME='{}'".format(data["sc_config"][sc_host]["sc_username"]))
1414
print("export SC_PASSWORD='{}'".format(data["sc_config"][sc_host]["sc_password"]))
15+
# SC_AUTH_METHOD==local by default, leave it unset
1516
EOF
1617
)"
1718

@@ -45,8 +46,34 @@ ansible-playbook -i localhost, -i hypercore_inventory_ansible_enable.yml run_ans
4546
ansible-playbook -i localhost, -i hypercore_inventory_ansible_disable.yml run_ansible_disable_tests.yml
4647
ansible-playbook -i localhost, -i hypercore_inventory_ansible_both_true.yml run_ansible_both_true_tests.yml
4748

48-
unset SC_TIMEOUT # do one test without SC_TIMEOUT
49-
49+
# do one test without SC_TIMEOUT
50+
unset SC_TIMEOUT
51+
ansible-playbook -i localhost, -i hypercore_inventory_ansible_both_false.yml run_ansible_both_false_tests.yml
52+
# test with SC_AUTH_METHOD being set to "local"
53+
export SC_AUTH_METHOD=local
5054
ansible-playbook -i localhost, -i hypercore_inventory_ansible_both_false.yml run_ansible_both_false_tests.yml
5155

56+
# test with OIDC user
57+
if [[ "$SC_HOST" == "https://10.5.11.50" ]]
58+
then
59+
# We can do this only if OIDC login is configured.
60+
echo "Testing inventory plugin with OIDC user."
61+
eval "$(cat <<EOF | python
62+
import yaml
63+
with open("$vars_file") as fd:
64+
data = yaml.safe_load(fd)
65+
sc_host=data["sc_host"]
66+
sc_timeout=data["sc_timeout"]
67+
print("export SC_HOST='{}'".format(sc_host))
68+
print("export SC_TIMEOUT='{}'".format(sc_timeout))
69+
print("export SC_USERNAME='{}'".format(data["sc_config"][sc_host]["oidc"]["users"][0]["username"]))
70+
print("export SC_PASSWORD='{}'".format(data["sc_config"][sc_host]["oidc"]["users"][0]["password"]))
71+
print("export SC_AUTH_METHOD=oidc")
72+
EOF
73+
)"
74+
ansible-playbook -i localhost, -i hypercore_inventory_ansible_both_false.yml run_ansible_both_false_tests.yml
75+
else
76+
echo "OIDC is not configured on host $SC_HOST, inventory plugin was not tested with OIDC user."
77+
fi
78+
5279
ansible-playbook cleanup.yml
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
# Test modules are able to login to Hypercore.
3+
# The local or oidc user accounts are supported.
4+
5+
# ===================================================================
6+
# local user, from environ
7+
- environment:
8+
SC_HOST: "{{ sc_host }}"
9+
SC_USERNAME: "{{ sc_config[sc_host].sc_username }}"
10+
SC_PASSWORD: "{{ sc_config[sc_host].sc_password }}"
11+
SC_TIMEOUT: "{{ sc_timeout }}"
12+
SC_AUTH_METHOD: local
13+
14+
block:
15+
- name: Get HC3 ping - local user, from environ
16+
scale_computing.hypercore.api:
17+
action: get
18+
endpoint: /rest/v1/ping
19+
register: ping_result
20+
- &assert_ping_result
21+
name: Check ping_result
22+
ansible.builtin.assert:
23+
that:
24+
- ping_result is not changed
25+
- ping_result.record.0 == "status"
26+
27+
# ===================================================================
28+
# local user, from cluster_instance variable
29+
- vars:
30+
cluster_instance:
31+
host: "{{ sc_host }}"
32+
username: "{{ sc_config[sc_host].sc_username }}"
33+
password: "{{ sc_config[sc_host].sc_password }}"
34+
auth_method: local
35+
block:
36+
- name: Get HC3 ping - local user, from cluster_instance variable
37+
scale_computing.hypercore.api:
38+
action: get
39+
endpoint: /rest/v1/ping
40+
cluster_instance: "{{ cluster_instance }}"
41+
register: ping_result
42+
- *assert_ping_result
43+
44+
# ===================================================================
45+
# OIDC user, from environ
46+
- environment:
47+
SC_HOST: "{{ sc_host }}"
48+
SC_USERNAME: "{{ sc_config[sc_host].oidc.users[0].username }}"
49+
SC_PASSWORD: "{{ sc_config[sc_host].oidc.users[0].password }}"
50+
SC_TIMEOUT: "{{ sc_timeout }}"
51+
SC_AUTH_METHOD: oidc
52+
block:
53+
- name: Get HC3 ping - OIDC user, from environ
54+
scale_computing.hypercore.api:
55+
action: get
56+
endpoint: /rest/v1/ping
57+
register: ping_result
58+
- *assert_ping_result
59+
60+
# ===================================================================
61+
# OIDC user, from cluster_instance variable
62+
- vars:
63+
cluster_instance: &cluster_instance_local
64+
host: "{{ sc_host }}"
65+
username: "{{ sc_config[sc_host].oidc.users[0].username }}"
66+
password: "{{ sc_config[sc_host].oidc.users[0].password }}"
67+
auth_method: oidc
68+
block:
69+
- name: Get HC3 ping - OIDC user, from cluster_instance variable
70+
scale_computing.hypercore.api:
71+
action: get
72+
endpoint: /rest/v1/ping
73+
cluster_instance: "{{ cluster_instance }}"
74+
register: ping_result
75+
- *assert_ping_result

0 commit comments

Comments
 (0)