Skip to content

Commit bc5b5fb

Browse files
authored
Merge pull request DIRACGrid#7901 from fstagni/cherry-pick-2-6504d155a-integration
[sweep:integration] Standard naming for TokenManager service classes
2 parents 695b017 + 6398e85 commit bc5b5fb

File tree

3 files changed

+312
-245
lines changed

3 files changed

+312
-245
lines changed

src/DIRAC/FrameworkSystem/ConfigTemplate.cfg

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ Services
2525
storeHostInfo = Operator
2626
}
2727
}
28+
##BEGIN TokenManager:
29+
# Section to describe TokenManager system
30+
TokenManager
31+
{
32+
Port = 9181
33+
# Description of rules for access to methods
34+
Authorization
35+
{
36+
# Settings by default:
37+
Default = authenticated
38+
getUsersTokensInfo = ProxyManagement
39+
}
40+
}
41+
##END
2842
##BEGIN TornadoTokenManager:
2943
# Section to describe TokenManager system
3044
TornadoTokenManager
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
"""TokenManager service is responsible for token management, namely storing, updating,
2+
requesting new tokens for DIRAC components that have the appropriate permissions.
3+
4+
.. literalinclude:: ../ConfigTemplate.cfg
5+
:start-after: ##BEGIN TokenManager:
6+
:end-before: ##END
7+
:dedent: 2
8+
:caption: TokenManager options
9+
10+
The most common use of this service is to obtain tokens with certain scope to return to the user for its purposes,
11+
or to provide to the DIRAC service to perform asynchronous tasks on behalf of the user.
12+
This is mainly about the :py:meth:`export_getToken` method.
13+
14+
.. image:: /_static/Systems/FS/TokenManager_getToken.png
15+
:alt: https://dirac.readthedocs.io/en/integration/_images/TokenManager_getToken.png (source https://github.com/TaykYoku/DIRACIMGS/raw/main/TokenManagerService_getToken.ai)
16+
17+
The client has a mechanism for caching the received tokens.
18+
This helps reduce the number of requests to both the service and the Identity Provider (IdP).
19+
20+
If the client has a valid **access token** in the cache, it is used until it expires.
21+
After that you need to update. The client can update it independently if on the server where it is in ``dirac.cfg``
22+
``client_id`` and ``client_secret`` of the Identity Provider client are registered.
23+
24+
Otherwise, the client makes an RPC call to the **TornadoManager** service.
25+
The ``refresh token`` from :py:class:`TokenDB <DIRAC.FrameworkSystem.DB.TokenDB.TokenDB>`
26+
is taken and the **exchange token** request to Identity Provider is made.
27+
"""
28+
29+
import pprint
30+
31+
from DIRAC import S_ERROR, S_OK
32+
from DIRAC.ConfigurationSystem.Client.Helpers import Registry
33+
from DIRAC.Core.DISET.RequestHandler import RequestHandler
34+
from DIRAC.Core.Security import Properties
35+
from DIRAC.FrameworkSystem.DB.TokenDB import TokenDB
36+
from DIRAC.FrameworkSystem.Utilities.TokenManagementUtilities import (
37+
getCachedKey,
38+
getIdProviderClient,
39+
)
40+
from DIRAC.Resources.IdProvider.IdProviderFactory import IdProviderFactory
41+
42+
43+
class TokenManagerHandlerMixin:
44+
DEFAULT_AUTHORIZATION = ["authenticated"]
45+
46+
@classmethod
47+
def initializeHandler(cls, *args):
48+
"""Initialization
49+
50+
:return: S_OK()/S_ERROR()
51+
"""
52+
53+
# The service plays an important OAuth 2.0 role, namely it is an Identity Provider client.
54+
# This allows you to manage tokens without the involvement of their owners.
55+
cls.idps = IdProviderFactory()
56+
57+
# Let's try to connect to the database
58+
try:
59+
cls.__tokenDB = TokenDB(parentLogger=cls.log)
60+
except Exception as e:
61+
cls.log.exception(e)
62+
return S_ERROR(f"Could not connect to the database {repr(e)}")
63+
64+
return S_OK()
65+
66+
auth_getUserTokensInfo = ["authenticated"]
67+
types_getUserTokensInfo = []
68+
69+
def export_getUserTokensInfo(self):
70+
"""Generate information dict about user tokens
71+
72+
:return: dict
73+
"""
74+
tokensInfo = []
75+
credDict = self.getRemoteCredentials()
76+
result = Registry.getDNForUsername(credDict["username"])
77+
if not result["OK"]:
78+
return result
79+
for dn in result["Value"]:
80+
result = Registry.getIDFromDN(dn)
81+
if result["OK"]:
82+
result = self.__tokenDB.getTokensByUserID(result["Value"])
83+
if not result["OK"]:
84+
return result
85+
tokensInfo += result["Value"]
86+
return S_OK(tokensInfo)
87+
88+
auth_getUsersTokensInfo = [Properties.PROXY_MANAGEMENT]
89+
types_getUserTokensInfo = [list]
90+
91+
def export_getUsersTokensInfo(self, users: list):
92+
"""Get the info about the user tokens in the database
93+
94+
:param users: user names
95+
96+
:return: S_OK(list) -- return list of tokens dictionaries
97+
"""
98+
tokensInfo = []
99+
for user in users:
100+
# Find the user ID among his DNs
101+
result = Registry.getDNForUsername(user)
102+
if not result["OK"]:
103+
return result
104+
for dn in result["Value"]:
105+
uid = Registry.getIDFromDN(dn).get("Value")
106+
if uid:
107+
result = self.__tokenDB.getTokensByUserID(uid)
108+
if not result["OK"]:
109+
self.log.error(result["Message"])
110+
else:
111+
for tokenDict in result["Value"]:
112+
if tokenDict not in tokensInfo:
113+
# The database does not contain a username,
114+
# as it is a unique user ID exclusively for DIRAC
115+
# and is not associated with a token.
116+
tokenDict["username"] = user
117+
tokensInfo.append(tokenDict)
118+
return S_OK(tokensInfo)
119+
120+
types_updateToken = [dict, str, str, int]
121+
122+
def export_updateToken(self, token: dict, userID: str, provider: str, rt_expired_in: int = 24 * 3600):
123+
"""Using this method, you can transfer user tokens for storage in the TokenManager.
124+
125+
It is important to note that TokenManager saves only one token per user and, accordingly,
126+
the Identity Provider from which it was issued. So when a new token is delegated,
127+
keep in mind that the old token will be deleted.
128+
129+
:param token: token
130+
:param userID: user ID
131+
:param provider: provider name
132+
:param rt_expired_in: refresh token expires time (in seconds)
133+
134+
:return: S_OK(list)/S_ERROR() -- list contain uploaded tokens info as dictionaries
135+
"""
136+
self.log.verbose(f"Update {userID} user token issued by {provider}:\n", pprint.pformat(token))
137+
# prepare the client instance of the appropriate IdP to revoke the old tokens
138+
result = self.idps.getIdProvider(provider)
139+
if not result["OK"]:
140+
return result
141+
idPObj = result["Value"]
142+
# overwrite old tokens with new ones
143+
result = self.__tokenDB.updateToken(token, userID, provider, rt_expired_in)
144+
if not result["OK"]:
145+
return result
146+
# revoke the old tokens
147+
for oldToken in result["Value"]:
148+
if "refresh_token" in oldToken and oldToken["refresh_token"] != token["refresh_token"]:
149+
self.log.verbose("Revoke old refresh token:\n", pprint.pformat(oldToken))
150+
idPObj.revokeToken(oldToken["refresh_token"])
151+
# Let's return to the current situation with the storage of user tokens
152+
return self.__tokenDB.getTokensByUserID(userID)
153+
154+
def __checkProperties(self, requestedUserDN: str, requestedUserGroup: str):
155+
"""Check the properties and return if they can only download limited tokens if authorized
156+
157+
:param requestedUserDN: user DN
158+
:param requestedUserGroup: DIRAC group
159+
160+
:return: S_OK(bool)/S_ERROR()
161+
"""
162+
credDict = self.getRemoteCredentials()
163+
if Properties.FULL_DELEGATION in credDict["properties"]:
164+
return S_OK(False)
165+
if Properties.LIMITED_DELEGATION in credDict["properties"]:
166+
return S_OK(True)
167+
if Properties.PRIVATE_LIMITED_DELEGATION in credDict["properties"]:
168+
if credDict["DN"] != requestedUserDN:
169+
return S_ERROR("You are not allowed to download any token")
170+
if Properties.PRIVATE_LIMITED_DELEGATION not in Registry.getPropertiesForGroup(requestedUserGroup):
171+
return S_ERROR("You can't download tokens for that group")
172+
return S_OK(True)
173+
# Not authorized!
174+
return S_ERROR("You can't get tokens!")
175+
176+
types_getToken = [None, None, None, None, None]
177+
178+
def export_getToken(
179+
self,
180+
username: str = None,
181+
userGroup: str = None,
182+
scope: list[str] = None,
183+
audience: str = None,
184+
identityProvider: str = None,
185+
requiredTimeLeft: int = 0,
186+
):
187+
"""Get an access token for a user/group.
188+
189+
* Properties:
190+
* FullDelegation <- permits full delegation of tokens
191+
* LimitedDelegation <- permits downloading only limited tokens
192+
* PrivateLimitedDelegation <- permits downloading only limited tokens for one self
193+
194+
:param username: user name
195+
:param userGroup: user group
196+
:param scope: requested scope
197+
:param audience: requested audience
198+
:param identityProvider: Identity Provider name
199+
:param requiredTimeLeft: requested minimum life time
200+
201+
:return: S_OK(dict)/S_ERROR()
202+
"""
203+
# Get an IdProvider Client instance
204+
result = getIdProviderClient(userGroup, identityProvider)
205+
if not result["OK"]:
206+
return result
207+
idpObj = result["Value"]
208+
209+
# getCachedKey is just used here to resolve the default scopes
210+
_, scope, *_ = getCachedKey(idpObj, username, userGroup, scope, audience)
211+
212+
# A client token is requested
213+
if not username:
214+
result = self.__checkProperties("", "")
215+
if not result["OK"]:
216+
return result
217+
218+
# Get the client token with requested scope and audience
219+
result = idpObj.fetchToken(grant_type="client_credentials", scope=scope, audience=audience)
220+
# DEncode can not encode OAuth2Token object
221+
if result["OK"]:
222+
result["Value"] = dict(result["Value"])
223+
224+
return result
225+
226+
# A user token is requested
227+
err = []
228+
# No luck so far, let's refresh the token stored in the database
229+
result = Registry.getDNForUsername(username)
230+
if not result["OK"]:
231+
return result
232+
for dn in result["Value"]:
233+
# For backward compatibility, the user ID is written as DN. So let's check if this DN contains a user ID
234+
result = Registry.getIDFromDN(dn)
235+
if result["OK"]:
236+
uid = result["Value"]
237+
# To do this, first find the refresh token stored in the database with the maximum scope
238+
result = self.__tokenDB.getTokenForUserProvider(uid, idpObj.name)
239+
if result["OK"] and result["Value"]:
240+
tokens = result["Value"]
241+
result = self.__checkProperties(dn, userGroup)
242+
if result["OK"]:
243+
# refresh token with requested scope
244+
result = idpObj.refreshToken(tokens.get("refresh_token"), group=userGroup, scope=scope)
245+
if result["OK"]:
246+
return result
247+
# Did not find any token associated with the found user ID
248+
err.append(result.get("Message", f"No token found for {uid}"))
249+
# Collect all errors when trying to get a token, or if no user ID is registered
250+
return S_ERROR("; ".join(err or [f"No user ID found for {username}"]))
251+
252+
types_deleteToken = [str]
253+
254+
def export_deleteToken(self, userDN: str):
255+
"""Delete a token from the DB
256+
257+
:param userDN: user DN
258+
259+
:return: S_OK()/S_ERROR()
260+
"""
261+
262+
# temporary ugly stuff to make it compliant with proxy management
263+
userDN = f"/O=DIRAC/CN={userDN}"
264+
265+
# Delete it from cache
266+
credDict = self.getRemoteCredentials()
267+
if Properties.PROXY_MANAGEMENT not in credDict["properties"]:
268+
if userDN != credDict["DN"]:
269+
return S_ERROR("You aren't allowed!")
270+
result = Registry.getIDFromDN(userDN)
271+
return self.__tokenDB.removeToken(user_id=result["Value"]) if result["OK"] else result
272+
273+
types_getTokensByUserID = [str]
274+
275+
def export_getTokensByUserID(self, userID: str):
276+
"""Retrieve a token from the DB
277+
278+
:param userID: user's token id
279+
280+
:return: S_OK(list)/S_ERROR() token row in dict format
281+
"""
282+
return self.__tokenDB.getTokensByUserID(userID)
283+
284+
285+
class TokenManagerHandler(TokenManagerHandlerMixin, RequestHandler):
286+
pass

0 commit comments

Comments
 (0)