Skip to content

Commit 7be3e29

Browse files
committed
Merge remote-tracking branch 'origin/master'
2 parents 9d1f218 + 7dec9bc commit 7be3e29

File tree

10 files changed

+1357
-428
lines changed

10 files changed

+1357
-428
lines changed

doc/api/services/saslogon.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
sasctl.services.saslogon
2+
========================
3+
4+
.. automodule:: sasctl._services.saslogon
5+
:members:
6+
:undoc-members:
7+
:show-inheritance:

src/sasctl/_services/saslogon.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
#!/usr/bin/env python
2+
# encoding: utf-8
3+
#
4+
# Copyright © 2022, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
"""The SAS Logon service provides standard OAuth endpoints for client management."""
8+
9+
from ..core import HTTPError
10+
from .service import Service
11+
12+
13+
class SASLogon(Service):
14+
"""The SAS Logon service client management related endpoints.
15+
16+
Provides functionality for managing client IDs and secrets This class
17+
is somewhat different from the other Service classes because many of
18+
the operations on the associated SA SLogon REST service are related to
19+
authentication. In sasctl all authentication is handled in the
20+
`Session` class, so only the operations that are not related to
21+
authentication are implemented here.
22+
23+
The operations provided by this service are only accessible to users
24+
with administrator permissions.
25+
26+
"""
27+
28+
_SERVICE_ROOT = "/SASLogon"
29+
30+
@classmethod
31+
def create_client(
32+
cls,
33+
client_id,
34+
client_secret,
35+
scopes=None,
36+
redirect_uri=None,
37+
allow_password=False,
38+
allow_client_secret=False,
39+
allow_auth_code=False,
40+
):
41+
"""Register a new client with the SAS Viya environment.
42+
43+
Parameters
44+
----------
45+
client_id : str
46+
The ID to be assigned to the client.
47+
client_secret : str
48+
The client secret used for authentication.
49+
scopes : list of string, optional
50+
Specifies the levels of access that the client will be able to
51+
obtain on behalf of users when not using client credential
52+
authentication. If `allow_password` or `allow_auth_code` are
53+
true, the 'openid' scope will also be included. This is used
54+
to assert the identity of the user that the client is acting on
55+
behalf of. For clients that only use client credential
56+
authentication and therefore do not act on behalf of users,
57+
the 'uaa.none' scope will automatically be included.
58+
redirect_uri : str, optional
59+
The allowed URI pattern for redirects during authorization.
60+
Defaults to 'urn:ietf:wg:oauth:2.0:oob'.
61+
allow_password : bool, optional
62+
Whether to allow username & password authentication with this
63+
client. Defaults to false.
64+
allow_client_secret : bool
65+
Whether to allow authentication using just the client ID and
66+
client secret. Defaults to false.
67+
allow_auth_code : bool, optional
68+
Whether to allow authorization code access using this client.
69+
Defaults to false.
70+
71+
Returns
72+
-------
73+
RestObj
74+
75+
"""
76+
scopes = set() if scopes is None else set(scopes)
77+
78+
# Include default scopes depending on allowed grant types
79+
if allow_password or allow_auth_code:
80+
scopes.add("openid")
81+
elif allow_client_secret:
82+
scopes.add("uaa.none")
83+
else:
84+
raise ValueError("At least one authentication method must be allowed.")
85+
86+
redirect_uri = redirect_uri or "urn:ietf:wg:oauth:2.0:oob"
87+
88+
grant_types = set()
89+
if allow_auth_code:
90+
grant_types.update(["authorization_code", "refresh_token"])
91+
if allow_client_secret:
92+
grant_types.add("client_credentials")
93+
if allow_password:
94+
grant_types.update(["password", "refresh_token"])
95+
96+
data = {
97+
"client_id": client_id,
98+
"client_secret": client_secret,
99+
"scope": list(scopes),
100+
"authorized_grant_types": list(grant_types),
101+
"redirect_uri": redirect_uri,
102+
}
103+
104+
# Use access token to define a new client, along with client secret & allowed
105+
# authorization types (auth code)
106+
response = cls.post("/oauth/clients", json=data)
107+
108+
return response
109+
110+
@classmethod
111+
def delete_client(cls, client):
112+
"""Remove and existing client.
113+
114+
Parameters
115+
----------
116+
client : str or RestObj
117+
The client ID or a RestObj containing the client details.
118+
119+
Returns
120+
-------
121+
RestObj
122+
The deleted client
123+
124+
Raises
125+
------
126+
ValueError
127+
If `client` is not found.
128+
129+
"""
130+
id_ = client.get("client_id") if isinstance(client, dict) else str(client)
131+
132+
try:
133+
return cls.delete(f"/oauth/clients/{id_}")
134+
except HTTPError as e:
135+
if e.code == 404:
136+
raise ValueError(f"Client with ID '{id_}' not found.") from e
137+
raise
138+
139+
@classmethod
140+
def get_client(cls, client_id):
141+
"""Retrieve information about a specific client
142+
143+
Parameters
144+
----------
145+
client_id : str
146+
The id of the client.
147+
148+
Returns
149+
-------
150+
RestObj or None
151+
152+
"""
153+
return cls.get(f"/oauth/clients/{client_id}")
154+
155+
@classmethod
156+
def list_clients(cls, start_index=None, count=None, descending=False):
157+
"""Retrieve a details of multiple clients.
158+
159+
Parameters
160+
----------
161+
start_index : int, optional
162+
Index of first client to return. Defaults to 1.
163+
count : int, optiona;
164+
Number of clients to retrieve. Defaults to 100.
165+
descending : bool, optional
166+
Whether to clients should be returned in descending order.
167+
168+
Returns
169+
-------
170+
list of dict
171+
Each dict contains details for a single client. If no
172+
clients were found and empty list is returned.
173+
174+
"""
175+
params = {}
176+
if start_index:
177+
params["startIndex"] = int(start_index)
178+
if count:
179+
params["count"] = int(count)
180+
if descending:
181+
params["sortOrder"] = "descending"
182+
183+
results = cls.get("/oauth/clients", params=params)
184+
185+
if results is None:
186+
return []
187+
188+
# Response does not conform to format expected by PagedList (items
189+
# under an 'items' property and a URL to request additional items).
190+
# Instead, just return the raw list.
191+
return results["resources"]
192+
193+
@classmethod
194+
def update_client_secret(cls, client, secret):
195+
"""
196+
197+
Parameters
198+
----------
199+
client : str or RestObj
200+
The client ID or a RestObj containing the client details.
201+
secret : str
202+
The new client secret.
203+
204+
Returns
205+
-------
206+
None
207+
208+
Raises
209+
------
210+
ValueError
211+
If `client` is not found.
212+
213+
"""
214+
id_ = client.get("client_id") if isinstance(client, dict) else str(client)
215+
216+
data = {"secret": secret}
217+
218+
try:
219+
# Ignoring response ({"status": "ok", "message": "secret updated"})
220+
_ = cls.put(f"/oauth/clients/{id_}/secret", json=data)
221+
except HTTPError as e:
222+
if e.code == 404:
223+
raise ValueError(f"Client with ID '{id_}' not found.") from e
224+
raise

0 commit comments

Comments
 (0)