RBAC permission storage in MongoDB
[osm/NBI.git] / osm_nbi / auth.py
1 # -*- coding: utf-8 -*-
2
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12 # implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
15
16
17 """
18 Authenticator is responsible for authenticating the users,
19 create the tokens unscoped and scoped, retrieve the role
20 list inside the projects that they are inserted
21 """
22 from os import path
23
24 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
25 __date__ = "$27-jul-2018 23:59:59$"
26
27 import cherrypy
28 import logging
29 import yaml
30 from base64 import standard_b64decode
31 from copy import deepcopy
32 from functools import reduce
33 from hashlib import sha256
34 from http import HTTPStatus
35 from random import choice as random_choice
36 from time import time
37 from uuid import uuid4
38
39 from authconn import AuthException
40 from authconn_keystone import AuthconnKeystone
41 from osm_common import dbmongo
42 from osm_common import dbmemory
43 from osm_common.dbbase import DbException
44
45
46 class Authenticator:
47 """
48 This class should hold all the mechanisms for User Authentication and
49 Authorization. Initially it should support Openstack Keystone as a
50 backend through a plugin model where more backends can be added and a
51 RBAC model to manage permissions on operations.
52 """
53
54 periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
55
56 def __init__(self):
57 """
58 Authenticator initializer. Setup the initial state of the object,
59 while it waits for the config dictionary and database initialization.
60 """
61 self.backend = None
62 self.config = None
63 self.db = None
64 self.tokens_cache = dict()
65 self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done
66 self.resources_to_operations_file = None
67 self.roles_to_operations_file = None
68 self.resources_to_operations_mapping = {}
69 self.operation_to_allowed_roles = {}
70 self.logger = logging.getLogger("nbi.authenticator")
71
72 def start(self, config):
73 """
74 Method to configure the Authenticator object. This method should be called
75 after object creation. It is responsible by initializing the selected backend,
76 as well as the initialization of the database connection.
77
78 :param config: dictionary containing the relevant parameters for this object.
79 """
80 self.config = config
81
82 try:
83 if not self.db:
84 if config["database"]["driver"] == "mongo":
85 self.db = dbmongo.DbMongo()
86 self.db.db_connect(config["database"])
87 elif config["database"]["driver"] == "memory":
88 self.db = dbmemory.DbMemory()
89 self.db.db_connect(config["database"])
90 else:
91 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
92 .format(config["database"]["driver"]))
93 if not self.backend:
94 if config["authentication"]["backend"] == "keystone":
95 self.backend = AuthconnKeystone(self.config["authentication"])
96 elif config["authentication"]["backend"] == "internal":
97 self._internal_tokens_prune()
98 else:
99 raise AuthException("Unknown authentication backend: {}"
100 .format(config["authentication"]["backend"]))
101 if not self.resources_to_operations_file:
102 if "resources_to_operations" in config["rbac"]:
103 self.resources_to_operations_file = config["rbac"]["resources_to_operations"]
104 else:
105 for config_file in (__file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml",
106 "./resources_to_operations.yml"):
107 if path.isfile(config_file):
108 self.resources_to_operations_file = config_file
109 break
110 if not self.resources_to_operations_file:
111 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
112 if not self.roles_to_operations_file:
113 if "roles_to_operations" in config["rbac"]:
114 self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
115 else:
116 for config_file in (__file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml",
117 "./roles_to_operations.yml"):
118 if path.isfile(config_file):
119 self.roles_to_operations_file = config_file
120 break
121 if not self.roles_to_operations_file:
122 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
123 except Exception as e:
124 raise AuthException(str(e))
125
126 def stop(self):
127 try:
128 if self.db:
129 self.db.db_disconnect()
130 except DbException as e:
131 raise AuthException(str(e), http_code=e.http_code)
132
133 def init_db(self, target_version='1.0'):
134 """
135 Check if the database has been initialized, with at least one user. If not, create the required tables
136 and insert the predefined mappings between roles and permissions.
137
138 :param target_version: schema version that should be present in the database.
139 :return: None if OK, exception if error or version is different.
140 """
141 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
142 # Operations encoding: "<METHOD> <URL>"
143 # Note: it is faster to rewrite the value than to check if it is already there or not
144 operations = []
145 with open(self.resources_to_operations_file, "r") as stream:
146 resources_to_operations_yaml = yaml.load(stream)
147
148 for resource, operation in resources_to_operations_yaml["resources_to_operations"].items():
149 operation_key = operation.replace(".", ":")
150 if operation_key not in operations:
151 operations.append(operation_key)
152 self.resources_to_operations_mapping[resource] = operation_key
153
154 records = self.db.get_list("roles_operations")
155
156 # Loading permissions to MongoDB. If there are permissions already in MongoDB, do nothing.
157 if len(records) == 0:
158 with open(self.roles_to_operations_file, "r") as stream:
159 roles_to_operations_yaml = yaml.load(stream)
160
161 roles = []
162 for role_with_operations in roles_to_operations_yaml["roles_to_operations"]:
163 # Verifying if role already exists. If it does, send warning to log and ignore it.
164 if role_with_operations["role"] not in roles:
165 roles.append(role_with_operations["role"])
166 else:
167 self.logger.warning("Duplicated role with name: {0}. Role definition is ignored."
168 .format(role_with_operations["role"]))
169 continue
170
171 operations = {}
172 root = None
173
174 if not role_with_operations["operations"]:
175 continue
176
177 for operation, is_allowed in role_with_operations["operations"].items():
178 if not isinstance(is_allowed, bool):
179 continue
180
181 if operation == ".":
182 root = is_allowed
183 continue
184
185 if len(operation) != 1 and operation[-1] == ".":
186 self.logger.warning("Invalid operation {0} terminated in '.'. "
187 "Operation will be discarded"
188 .format(operation))
189 continue
190
191 operation_key = operation.replace(".", ":")
192 if operation_key not in operations.keys():
193 operations[operation_key] = is_allowed
194 else:
195 self.logger.info("In role {0}, the operation {1} with the value {2} was discarded due to "
196 "repetition.".format(role_with_operations["role"], operation, is_allowed))
197
198 if not root:
199 root = False
200 self.logger.info("Root for role {0} not defined. Default value 'False' applied."
201 .format(role_with_operations["role"]))
202
203 now = time()
204 operation_to_roles_item = {
205 "_id": str(uuid4()),
206 "_admin": {
207 "created": now,
208 "modified": now,
209 },
210 "role": role_with_operations["role"],
211 "root": root
212 }
213
214 for operation, value in operations.items():
215 operation_to_roles_item[operation] = value
216
217 self.db.create("roles_operations", operation_to_roles_item)
218
219 permissions = {oper: [] for oper in operations}
220 records = self.db.get_list("roles_operations")
221
222 ignore_fields = ["_id", "_admin", "role", "root"]
223 roles = []
224 for record in records:
225
226 roles.append(record["role"])
227 record_permissions = {oper: record["root"] for oper in operations}
228 operations_joined = [(oper, value) for oper, value in record.items() if oper not in ignore_fields]
229 operations_joined.sort(key=lambda x: x[0].count(":"))
230
231 for oper in operations_joined:
232 match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()))
233
234 for m in match:
235 record_permissions[m] = oper[1]
236
237 allowed_operations = [k for k, v in record_permissions.items() if v is True]
238
239 for allowed_op in allowed_operations:
240 permissions[allowed_op].append(record["role"])
241
242 for oper, role_list in permissions.items():
243 self.operation_to_allowed_roles[oper] = role_list
244
245 if self.config["authentication"]["backend"] != "internal":
246 for role in roles:
247 if role == "anonymous":
248 continue
249 self.backend.create_role(role)
250
251 self.backend.assign_role_to_user("admin", "admin", "system_admin")
252
253 def authorize(self):
254 token = None
255 user_passwd64 = None
256 try:
257 # 1. Get token Authorization bearer
258 auth = cherrypy.request.headers.get("Authorization")
259 if auth:
260 auth_list = auth.split(" ")
261 if auth_list[0].lower() == "bearer":
262 token = auth_list[-1]
263 elif auth_list[0].lower() == "basic":
264 user_passwd64 = auth_list[-1]
265 if not token:
266 if cherrypy.session.get("Authorization"):
267 # 2. Try using session before request a new token. If not, basic authentication will generate
268 token = cherrypy.session.get("Authorization")
269 if token == "logout":
270 token = None # force Unauthorized response to insert user password again
271 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
272 # 3. Get new token from user password
273 user = None
274 passwd = None
275 try:
276 user_passwd = standard_b64decode(user_passwd64).decode()
277 user, _, passwd = user_passwd.partition(":")
278 except Exception:
279 pass
280 outdata = self.new_token(None, {"username": user, "password": passwd})
281 token = outdata["id"]
282 cherrypy.session['Authorization'] = token
283 if self.config["authentication"]["backend"] == "internal":
284 return self._internal_authorize(token)
285 else:
286 if not token:
287 raise AuthException("Needed a token or Authorization http header",
288 http_code=HTTPStatus.UNAUTHORIZED)
289 try:
290 self.backend.validate_token(token)
291 self.check_permissions(self.tokens_cache[token], cherrypy.request.path_info,
292 cherrypy.request.method)
293 # TODO: check if this can be avoided. Backend may provide enough information
294 return deepcopy(self.tokens_cache[token])
295 except AuthException:
296 self.del_token(token)
297 raise
298 except AuthException as e:
299 if cherrypy.session.get('Authorization'):
300 del cherrypy.session['Authorization']
301 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
302 raise AuthException(str(e))
303
304 def new_token(self, session, indata, remote):
305 if self.config["authentication"]["backend"] == "internal":
306 return self._internal_new_token(session, indata, remote)
307 else:
308 if indata.get("username"):
309 token, projects = self.backend.authenticate_with_user_password(
310 indata.get("username"), indata.get("password"))
311 elif session:
312 token, projects = self.backend.authenticate_with_token(
313 session.get("id"), indata.get("project_id"))
314 else:
315 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
316 http_code=HTTPStatus.UNAUTHORIZED)
317
318 if indata.get("project_id"):
319 project_id = indata.get("project_id")
320 if project_id not in projects:
321 raise AuthException("Project {} not allowed for this user".format(project_id),
322 http_code=HTTPStatus.UNAUTHORIZED)
323 else:
324 project_id = projects[0]
325
326 if not session:
327 token, projects = self.backend.authenticate_with_token(token, project_id)
328
329 if project_id == "admin":
330 session_admin = True
331 else:
332 session_admin = reduce(lambda x, y: x or (True if y == "admin" else False),
333 projects, False)
334
335 now = time()
336 new_session = {
337 "_id": token,
338 "id": token,
339 "issued_at": now,
340 "expires": now + 3600,
341 "project_id": project_id,
342 "username": indata.get("username") if not session else session.get("username"),
343 "remote_port": remote.port,
344 "admin": session_admin
345 }
346
347 if remote.name:
348 new_session["remote_host"] = remote.name
349 elif remote.ip:
350 new_session["remote_host"] = remote.ip
351
352 # TODO: check if this can be avoided. Backend may provide enough information
353 self.tokens_cache[token] = new_session
354
355 return deepcopy(new_session)
356
357 def get_token_list(self, session):
358 if self.config["authentication"]["backend"] == "internal":
359 return self._internal_get_token_list(session)
360 else:
361 # TODO: check if this can be avoided. Backend may provide enough information
362 return [deepcopy(token) for token in self.tokens_cache.values()
363 if token["username"] == session["username"]]
364
365 def get_token(self, session, token):
366 if self.config["authentication"]["backend"] == "internal":
367 return self._internal_get_token(session, token)
368 else:
369 # TODO: check if this can be avoided. Backend may provide enough information
370 token_value = self.tokens_cache.get(token)
371 if not token_value:
372 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
373 if token_value["username"] != session["username"] and not session["admin"]:
374 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
375 return token_value
376
377 def del_token(self, token):
378 if self.config["authentication"]["backend"] == "internal":
379 return self._internal_del_token(token)
380 else:
381 try:
382 self.backend.revoke_token(token)
383 del self.tokens_cache[token]
384 return "token '{}' deleted".format(token)
385 except KeyError:
386 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
387
388 def check_permissions(self, session, url, method):
389 self.logger.info("Session: {}".format(session))
390 self.logger.info("URL: {}".format(url))
391 self.logger.info("Method: {}".format(method))
392
393 key, parameters = self._normalize_url(url, method)
394
395 # TODO: Check if parameters might be useful for the decision
396
397 operation = self.resources_to_operations_mapping[key]
398 roles_required = self.operation_to_allowed_roles[operation]
399 roles_allowed = self.backend.get_role_list(session["id"])
400
401 if "anonymous" in roles_required:
402 return
403
404 for role in roles_allowed:
405 if role in roles_required:
406 return
407
408 raise AuthException("Access denied: lack of permissions.")
409
410 def _normalize_url(self, url, method):
411 # Removing query strings
412 normalized_url = url if '?' not in url else url[:url.find("?")]
413 normalized_url_splitted = normalized_url.split("/")
414 parameters = {}
415
416 filtered_keys = [key for key in self.resources_to_operations_mapping.keys()
417 if method in key.split()[0]]
418
419 for idx, path_part in enumerate(normalized_url_splitted):
420 tmp_keys = []
421 for tmp_key in filtered_keys:
422 splitted = tmp_key.split()[1].split("/")
423 if "<" in splitted[idx] and ">" in splitted[idx]:
424 if splitted[idx] == "<artifactPath>":
425 tmp_keys.append(tmp_key)
426 continue
427 elif idx == len(normalized_url_splitted) - 1 and \
428 len(normalized_url_splitted) != len(splitted):
429 continue
430 else:
431 tmp_keys.append(tmp_key)
432 elif splitted[idx] == path_part:
433 if idx == len(normalized_url_splitted) - 1 and \
434 len(normalized_url_splitted) != len(splitted):
435 continue
436 else:
437 tmp_keys.append(tmp_key)
438 filtered_keys = tmp_keys
439 if len(filtered_keys) == 1 and \
440 filtered_keys[0].split("/")[-1] == "<artifactPath>":
441 break
442
443 if len(filtered_keys) == 0:
444 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url))
445 elif len(filtered_keys) > 1:
446 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url))
447
448 filtered_key = filtered_keys[0]
449
450 for idx, path_part in enumerate(filtered_key.split()[1].split("/")):
451 if "<" in path_part and ">" in path_part:
452 if path_part == "<artifactPath>":
453 parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:])
454 else:
455 parameters[path_part[1:-1]] = normalized_url_splitted[idx]
456
457 return filtered_key, parameters
458
459 def _internal_authorize(self, token_id):
460 try:
461 if not token_id:
462 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
463 # try to get from cache first
464 now = time()
465 session = self.tokens_cache.get(token_id)
466 if session and session["expires"] < now:
467 del self.tokens_cache[token_id]
468 session = None
469 if session:
470 return session
471
472 # get from database if not in cache
473 session = self.db.get_one("tokens", {"_id": token_id})
474 if session["expires"] < now:
475 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
476 self.tokens_cache[token_id] = session
477 return session
478 except DbException as e:
479 if e.http_code == HTTPStatus.NOT_FOUND:
480 raise AuthException("Invalid Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
481 else:
482 raise
483
484 except AuthException:
485 if self.config["global"].get("test.user_not_authorized"):
486 return {"id": "fake-token-id-for-test",
487 "project_id": self.config["global"].get("test.project_not_authorized", "admin"),
488 "username": self.config["global"]["test.user_not_authorized"]}
489 else:
490 raise
491
492 def _internal_new_token(self, session, indata, remote):
493 now = time()
494 user_content = None
495
496 # Try using username/password
497 if indata.get("username"):
498 user_rows = self.db.get_list("users", {"username": indata.get("username")})
499 if user_rows:
500 user_content = user_rows[0]
501 salt = user_content["_admin"]["salt"]
502 shadow_password = sha256(indata.get("password", "").encode('utf-8') + salt.encode('utf-8')).hexdigest()
503 if shadow_password != user_content["password"]:
504 user_content = None
505 if not user_content:
506 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
507 elif session:
508 user_rows = self.db.get_list("users", {"username": session["username"]})
509 if user_rows:
510 user_content = user_rows[0]
511 else:
512 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
513 else:
514 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
515 http_code=HTTPStatus.UNAUTHORIZED)
516
517 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
518 for _ in range(0, 32))
519 if indata.get("project_id"):
520 project_id = indata.get("project_id")
521 if project_id not in user_content["projects"]:
522 raise AuthException("project {} not allowed for this user"
523 .format(project_id), http_code=HTTPStatus.UNAUTHORIZED)
524 else:
525 project_id = user_content["projects"][0]
526 if project_id == "admin":
527 session_admin = True
528 else:
529 project = self.db.get_one("projects", {"_id": project_id})
530 session_admin = project.get("admin", False)
531 new_session = {"issued_at": now, "expires": now + 3600,
532 "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"],
533 "remote_port": remote.port, "admin": session_admin}
534 if remote.name:
535 new_session["remote_host"] = remote.name
536 elif remote.ip:
537 new_session["remote_host"] = remote.ip
538
539 self.tokens_cache[token_id] = new_session
540 self.db.create("tokens", new_session)
541 # check if database must be prune
542 self._internal_tokens_prune(now)
543 return deepcopy(new_session)
544
545 def _internal_get_token_list(self, session):
546 now = time()
547 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
548 return token_list
549
550 def _internal_get_token(self, session, token_id):
551 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
552 if not token_value:
553 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
554 if token_value["username"] != session["username"] and not session["admin"]:
555 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
556 return token_value
557
558 def _internal_del_token(self, token_id):
559 try:
560 self.tokens_cache.pop(token_id, None)
561 self.db.del_one("tokens", {"_id": token_id})
562 return "token '{}' deleted".format(token_id)
563 except DbException as e:
564 if e.http_code == HTTPStatus.NOT_FOUND:
565 raise AuthException("Token '{}' not found".format(token_id), http_code=HTTPStatus.NOT_FOUND)
566 else:
567 raise
568
569 def _internal_tokens_prune(self, now=None):
570 now = now or time()
571 if not self.next_db_prune_time or self.next_db_prune_time >= now:
572 self.db.del_list("tokens", {"expires.lt": now})
573 self.next_db_prune_time = self.periodin_db_pruning + now
574 self.tokens_cache.clear() # force to reload tokens from database