1""" 2This module contains routines used to verify the matcher against the minions 3expected to return 4""" 5 6 7import fnmatch 8import logging 9import os 10import re 11 12import salt.auth.ldap 13import salt.cache 14import salt.payload 15import salt.roster 16import salt.utils.data 17import salt.utils.files 18import salt.utils.network 19import salt.utils.stringutils 20import salt.utils.versions 21from salt._compat import ipaddress 22from salt.defaults import DEFAULT_TARGET_DELIM 23from salt.exceptions import CommandExecutionError, SaltCacheError 24 25HAS_RANGE = False 26try: 27 import seco.range # pylint: disable=import-error 28 29 HAS_RANGE = True 30except ImportError: 31 pass 32 33log = logging.getLogger(__name__) 34 35TARGET_REX = re.compile( 36 r"""(?x) 37 ( 38 (?P<engine>G|P|I|J|L|N|S|E|R) # Possible target engines 39 (?P<delimiter>(?<=G|P|I|J).)? # Optional delimiter for specific engines 40 @)? # Engine+delimiter are separated by a '@' 41 # character and are optional for the target 42 (?P<pattern>.+)$""" # The pattern passed to the target engine 43) 44 45 46def _nodegroup_regex(nodegroup, words, opers): 47 opers_set = set(opers) 48 ret = words 49 if (set(ret) - opers_set) == set(ret): 50 # No compound operators found in nodegroup definition. Check for 51 # group type specifiers 52 group_type_re = re.compile("^[A-Z]@") 53 regex_chars = ["(", "[", "{", "\\", "?", "}", "]", ")"] 54 if not [x for x in ret if "*" in x or group_type_re.match(x)]: 55 # No group type specifiers and no wildcards. 56 # Treat this as an expression. 57 if [x for x in ret if x in [x for y in regex_chars if y in x]]: 58 joined = "E@" + ",".join(ret) 59 log.debug( 60 "Nodegroup '%s' (%s) detected as an expression. " 61 "Assuming compound matching syntax of '%s'", 62 nodegroup, 63 ret, 64 joined, 65 ) 66 else: 67 # Treat this as a list of nodenames. 68 joined = "L@" + ",".join(ret) 69 log.debug( 70 "Nodegroup '%s' (%s) detected as list of nodenames. " 71 "Assuming compound matching syntax of '%s'", 72 nodegroup, 73 ret, 74 joined, 75 ) 76 # Return data must be a list of compound matching components 77 # to be fed into compound matcher. Enclose return data in list. 78 return [joined] 79 80 81def parse_target(target_expression): 82 """Parse `target_expressing` splitting it into `engine`, `delimiter`, 83 `pattern` - returns a dict""" 84 85 match = TARGET_REX.match(target_expression) 86 if not match: 87 log.warning('Unable to parse target "%s"', target_expression) 88 ret = { 89 "engine": None, 90 "delimiter": None, 91 "pattern": target_expression, 92 } 93 else: 94 ret = match.groupdict() 95 return ret 96 97 98def get_minion_data(minion, opts): 99 """ 100 Get the grains/pillar for a specific minion. If minion is None, it 101 will return the grains/pillar for the first minion it finds. 102 103 Return value is a tuple of the minion ID, grains, and pillar 104 """ 105 grains = None 106 pillar = None 107 if opts.get("minion_data_cache", False): 108 cache = salt.cache.factory(opts) 109 if minion is None: 110 for id_ in cache.list("minions"): 111 data = cache.fetch("minions/{}".format(id_), "data") 112 if data is None: 113 continue 114 else: 115 data = cache.fetch("minions/{}".format(minion), "data") 116 if data is not None: 117 grains = data.get("grains", None) 118 pillar = data.get("pillar", None) 119 return minion if minion else None, grains, pillar 120 121 122def nodegroup_comp(nodegroup, nodegroups, skip=None, first_call=True): 123 """ 124 Recursively expand ``nodegroup`` from ``nodegroups``; ignore nodegroups in ``skip`` 125 126 If a top-level (non-recursive) call finds no nodegroups, return the original 127 nodegroup definition (for backwards compatibility). Keep track of recursive 128 calls via `first_call` argument 129 """ 130 expanded_nodegroup = False 131 if skip is None: 132 skip = set() 133 elif nodegroup in skip: 134 log.error( 135 'Failed nodegroup expansion: illegal nested nodegroup "%s"', nodegroup 136 ) 137 return "" 138 139 if nodegroup not in nodegroups: 140 log.error('Failed nodegroup expansion: unknown nodegroup "%s"', nodegroup) 141 return "" 142 143 nglookup = nodegroups[nodegroup] 144 if isinstance(nglookup, str): 145 words = nglookup.split() 146 elif isinstance(nglookup, (list, tuple)): 147 words = nglookup 148 else: 149 log.error( 150 "Nodegroup '%s' (%s) is neither a string, list nor tuple", 151 nodegroup, 152 nglookup, 153 ) 154 return "" 155 156 skip.add(nodegroup) 157 ret = [] 158 opers = ["and", "or", "not", "(", ")"] 159 for word in words: 160 if not isinstance(word, str): 161 word = str(word) 162 if word in opers: 163 ret.append(word) 164 elif len(word) >= 3 and word.startswith("N@"): 165 expanded_nodegroup = True 166 ret.extend( 167 nodegroup_comp(word[2:], nodegroups, skip=skip, first_call=False) 168 ) 169 else: 170 ret.append(word) 171 172 if ret: 173 ret.insert(0, "(") 174 ret.append(")") 175 176 skip.remove(nodegroup) 177 178 log.debug("nodegroup_comp(%s) => %s", nodegroup, ret) 179 # Only return list form if a nodegroup was expanded. Otherwise return 180 # the original string to conserve backwards compat 181 if expanded_nodegroup or not first_call: 182 if not first_call: 183 joined = _nodegroup_regex(nodegroup, words, opers) 184 if joined: 185 return joined 186 return ret 187 else: 188 ret = words 189 joined = _nodegroup_regex(nodegroup, ret, opers) 190 if joined: 191 return joined 192 193 log.debug( 194 "No nested nodegroups detected. Using original nodegroup definition: %s", 195 nodegroups[nodegroup], 196 ) 197 return ret 198 199 200class CkMinions: 201 """ 202 Used to check what minions should respond from a target 203 204 Note: This is a best-effort set of the minions that would match a target. 205 Depending on master configuration (grains caching, etc.) and topology (syndics) 206 the list may be a subset-- but we err on the side of too-many minions in this 207 class. 208 """ 209 210 def __init__(self, opts): 211 self.opts = opts 212 self.cache = salt.cache.factory(opts) 213 # TODO: this is actually an *auth* check 214 if self.opts.get("transport", "zeromq") in ("zeromq", "tcp"): 215 self.acc = "minions" 216 else: 217 self.acc = "accepted" 218 219 def _check_nodegroup_minions(self, expr, greedy): # pylint: disable=unused-argument 220 """ 221 Return minions found by looking at nodegroups 222 """ 223 return self._check_compound_minions( 224 nodegroup_comp(expr, self.opts["nodegroups"]), DEFAULT_TARGET_DELIM, greedy 225 ) 226 227 def _check_glob_minions(self, expr, greedy): # pylint: disable=unused-argument 228 """ 229 Return the minions found by looking via globs 230 """ 231 return {"minions": fnmatch.filter(self._pki_minions(), expr), "missing": []} 232 233 def _check_list_minions( 234 self, expr, greedy, ignore_missing=False 235 ): # pylint: disable=unused-argument 236 """ 237 Return the minions found by looking via a list 238 """ 239 if isinstance(expr, str): 240 expr = [m for m in expr.split(",") if m] 241 minions = self._pki_minions() 242 return { 243 "minions": [x for x in expr if x in minions], 244 "missing": [] if ignore_missing else [x for x in expr if x not in minions], 245 } 246 247 def _check_pcre_minions(self, expr, greedy): # pylint: disable=unused-argument 248 """ 249 Return the minions found by looking via regular expressions 250 """ 251 reg = re.compile(expr) 252 return { 253 "minions": [m for m in self._pki_minions() if reg.match(m)], 254 "missing": [], 255 } 256 257 def _pki_minions(self): 258 """ 259 Retreive complete minion list from PKI dir. 260 Respects cache if configured 261 """ 262 minions = [] 263 pki_cache_fn = os.path.join(self.opts["pki_dir"], self.acc, ".key_cache") 264 try: 265 os.makedirs(os.path.dirname(pki_cache_fn)) 266 except OSError: 267 pass 268 try: 269 if self.opts["key_cache"] and os.path.exists(pki_cache_fn): 270 log.debug("Returning cached minion list") 271 with salt.utils.files.fopen(pki_cache_fn, mode="rb") as fn_: 272 return salt.payload.load(fn_) 273 else: 274 for fn_ in salt.utils.data.sorted_ignorecase( 275 os.listdir(os.path.join(self.opts["pki_dir"], self.acc)) 276 ): 277 if not fn_.startswith(".") and os.path.isfile( 278 os.path.join(self.opts["pki_dir"], self.acc, fn_) 279 ): 280 minions.append(fn_) 281 return minions 282 except OSError as exc: 283 log.error( 284 "Encountered OSError while evaluating minions in PKI dir: %s", exc 285 ) 286 return minions 287 288 def _check_cache_minions( 289 self, expr, delimiter, greedy, search_type, regex_match=False, exact_match=False 290 ): 291 """ 292 Helper function to search for minions in master caches If 'greedy', 293 then return accepted minions matched by the condition or those absent 294 from the cache. If not 'greedy' return the only minions have cache 295 data and matched by the condition. 296 """ 297 cache_enabled = self.opts.get("minion_data_cache", False) 298 299 def list_cached_minions(): 300 return self.cache.list("minions") 301 302 if greedy: 303 minions = [] 304 for fn_ in salt.utils.data.sorted_ignorecase( 305 os.listdir(os.path.join(self.opts["pki_dir"], self.acc)) 306 ): 307 if not fn_.startswith(".") and os.path.isfile( 308 os.path.join(self.opts["pki_dir"], self.acc, fn_) 309 ): 310 minions.append(fn_) 311 elif cache_enabled: 312 minions = list_cached_minions() 313 else: 314 return {"minions": [], "missing": []} 315 316 if cache_enabled: 317 if greedy: 318 cminions = list_cached_minions() 319 else: 320 cminions = minions 321 if not cminions: 322 return {"minions": minions, "missing": []} 323 minions = set(minions) 324 for id_ in cminions: 325 if greedy and id_ not in minions: 326 continue 327 mdata = self.cache.fetch("minions/{}".format(id_), "data") 328 if mdata is None: 329 if not greedy: 330 minions.remove(id_) 331 continue 332 search_results = mdata.get(search_type) 333 if not salt.utils.data.subdict_match( 334 search_results, 335 expr, 336 delimiter=delimiter, 337 regex_match=regex_match, 338 exact_match=exact_match, 339 ): 340 minions.remove(id_) 341 minions = list(minions) 342 return {"minions": minions, "missing": []} 343 344 def _check_grain_minions(self, expr, delimiter, greedy): 345 """ 346 Return the minions found by looking via grains 347 """ 348 return self._check_cache_minions(expr, delimiter, greedy, "grains") 349 350 def _check_grain_pcre_minions(self, expr, delimiter, greedy): 351 """ 352 Return the minions found by looking via grains with PCRE 353 """ 354 return self._check_cache_minions( 355 expr, delimiter, greedy, "grains", regex_match=True 356 ) 357 358 def _check_pillar_minions(self, expr, delimiter, greedy): 359 """ 360 Return the minions found by looking via pillar 361 """ 362 return self._check_cache_minions(expr, delimiter, greedy, "pillar") 363 364 def _check_pillar_pcre_minions(self, expr, delimiter, greedy): 365 """ 366 Return the minions found by looking via pillar with PCRE 367 """ 368 return self._check_cache_minions( 369 expr, delimiter, greedy, "pillar", regex_match=True 370 ) 371 372 def _check_pillar_exact_minions(self, expr, delimiter, greedy): 373 """ 374 Return the minions found by looking via pillar 375 """ 376 return self._check_cache_minions( 377 expr, delimiter, greedy, "pillar", exact_match=True 378 ) 379 380 def _check_ipcidr_minions(self, expr, greedy): 381 """ 382 Return the minions found by looking via ipcidr 383 """ 384 cache_enabled = self.opts.get("minion_data_cache", False) 385 386 if greedy: 387 minions = self._pki_minions() 388 elif cache_enabled: 389 minions = self.cache.list("minions") 390 else: 391 return {"minions": [], "missing": []} 392 393 if cache_enabled: 394 if greedy: 395 cminions = self.cache.list("minions") 396 else: 397 cminions = minions 398 if cminions is None: 399 return {"minions": minions, "missing": []} 400 401 tgt = expr 402 try: 403 # Target is an address? 404 tgt = ipaddress.ip_address(tgt) 405 except Exception: # pylint: disable=broad-except 406 try: 407 # Target is a network? 408 tgt = ipaddress.ip_network(tgt) 409 except Exception: # pylint: disable=broad-except 410 log.error("Invalid IP/CIDR target: %s", tgt) 411 return {"minions": [], "missing": []} 412 proto = "ipv{}".format(tgt.version) 413 414 minions = set(minions) 415 for id_ in cminions: 416 mdata = self.cache.fetch("minions/{}".format(id_), "data") 417 if mdata is None: 418 if not greedy: 419 minions.remove(id_) 420 continue 421 grains = mdata.get("grains") 422 if grains is None or proto not in grains: 423 match = False 424 elif isinstance(tgt, (ipaddress.IPv4Address, ipaddress.IPv6Address)): 425 match = str(tgt) in grains[proto] 426 else: 427 match = salt.utils.network.in_subnet(tgt, grains[proto]) 428 429 if not match and id_ in minions: 430 minions.remove(id_) 431 432 return {"minions": list(minions), "missing": []} 433 434 def _check_range_minions(self, expr, greedy): 435 """ 436 Return the minions found by looking via range expression 437 """ 438 if not HAS_RANGE: 439 raise CommandExecutionError( 440 "Range matcher unavailable (unable to import seco.range, " 441 "module most likely not installed)" 442 ) 443 if not hasattr(self, "_range"): 444 self._range = seco.range.Range(self.opts["range_server"]) 445 try: 446 return self._range.expand(expr) 447 except seco.range.RangeException as exc: 448 log.error("Range exception in compound match: %s", exc) 449 cache_enabled = self.opts.get("minion_data_cache", False) 450 if greedy: 451 mlist = [] 452 for fn_ in salt.utils.data.sorted_ignorecase( 453 os.listdir(os.path.join(self.opts["pki_dir"], self.acc)) 454 ): 455 if not fn_.startswith(".") and os.path.isfile( 456 os.path.join(self.opts["pki_dir"], self.acc, fn_) 457 ): 458 mlist.append(fn_) 459 return {"minions": mlist, "missing": []} 460 elif cache_enabled: 461 return {"minions": self.cache.list("minions"), "missing": []} 462 else: 463 return {"minions": [], "missing": []} 464 465 def _check_compound_pillar_exact_minions(self, expr, delimiter, greedy): 466 """ 467 Return the minions found by looking via compound matcher 468 469 Disable pillar glob matching 470 """ 471 return self._check_compound_minions(expr, delimiter, greedy, pillar_exact=True) 472 473 def _check_compound_minions( 474 self, expr, delimiter, greedy, pillar_exact=False 475 ): # pylint: disable=unused-argument 476 """ 477 Return the minions found by looking via compound matcher 478 """ 479 if not isinstance(expr, str) and not isinstance(expr, (list, tuple)): 480 log.error("Compound target that is neither string, list nor tuple") 481 return {"minions": [], "missing": []} 482 minions = set(self._pki_minions()) 483 log.debug("minions: %s", minions) 484 485 nodegroups = self.opts.get("nodegroups", {}) 486 487 if self.opts.get("minion_data_cache", False): 488 ref = { 489 "G": self._check_grain_minions, 490 "P": self._check_grain_pcre_minions, 491 "I": self._check_pillar_minions, 492 "J": self._check_pillar_pcre_minions, 493 "L": self._check_list_minions, 494 "N": None, # nodegroups should already be expanded 495 "S": self._check_ipcidr_minions, 496 "E": self._check_pcre_minions, 497 "R": self._all_minions, 498 } 499 if pillar_exact: 500 ref["I"] = self._check_pillar_exact_minions 501 ref["J"] = self._check_pillar_exact_minions 502 503 results = [] 504 unmatched = [] 505 opers = ["and", "or", "not", "(", ")"] 506 missing = [] 507 508 if isinstance(expr, str): 509 words = expr.split() 510 else: 511 # we make a shallow copy in order to not affect the passed in arg 512 words = expr[:] 513 514 while words: 515 word = words.pop(0) 516 target_info = parse_target(word) 517 518 # Easy check first 519 if word in opers: 520 if results: 521 if results[-1] == "(" and word in ("and", "or"): 522 log.error('Invalid beginning operator after "(": %s', word) 523 return {"minions": [], "missing": []} 524 if word == "not": 525 if not results[-1] in ("&", "|", "("): 526 results.append("&") 527 results.append("(") 528 results.append(str(set(minions))) 529 results.append("-") 530 unmatched.append("-") 531 elif word == "and": 532 results.append("&") 533 elif word == "or": 534 results.append("|") 535 elif word == "(": 536 results.append(word) 537 unmatched.append(word) 538 elif word == ")": 539 if not unmatched or unmatched[-1] != "(": 540 log.error( 541 "Invalid compound expr (unexpected " 542 "right parenthesis): %s", 543 expr, 544 ) 545 return {"minions": [], "missing": []} 546 results.append(word) 547 unmatched.pop() 548 if unmatched and unmatched[-1] == "-": 549 results.append(")") 550 unmatched.pop() 551 else: # Won't get here, unless oper is added 552 log.error("Unhandled oper in compound expr: %s", expr) 553 return {"minions": [], "missing": []} 554 else: 555 # seq start with oper, fail 556 if word == "not": 557 results.append("(") 558 results.append(str(set(minions))) 559 results.append("-") 560 unmatched.append("-") 561 elif word == "(": 562 results.append(word) 563 unmatched.append(word) 564 else: 565 log.error( 566 "Expression may begin with binary operator: %s", word 567 ) 568 return {"minions": [], "missing": []} 569 570 elif target_info and target_info["engine"]: 571 if "N" == target_info["engine"]: 572 # if we encounter a node group, just evaluate it in-place 573 decomposed = nodegroup_comp(target_info["pattern"], nodegroups) 574 if decomposed: 575 words = decomposed + words 576 continue 577 578 engine = ref.get(target_info["engine"]) 579 if not engine: 580 # If an unknown engine is called at any time, fail out 581 log.error( 582 'Unrecognized target engine "%s" for' 583 ' target expression "%s"', 584 target_info["engine"], 585 word, 586 ) 587 return {"minions": [], "missing": []} 588 589 engine_args = [target_info["pattern"]] 590 if target_info["engine"] in ("G", "P", "I", "J"): 591 engine_args.append(target_info["delimiter"] or ":") 592 engine_args.append(greedy) 593 594 # ignore missing minions for lists if we exclude them with 595 # a 'not' 596 if "L" == target_info["engine"]: 597 engine_args.append(results and results[-1] == "-") 598 _results = engine(*engine_args) 599 results.append(str(set(_results["minions"]))) 600 missing.extend(_results["missing"]) 601 if unmatched and unmatched[-1] == "-": 602 results.append(")") 603 unmatched.pop() 604 605 else: 606 # The match is not explicitly defined, evaluate as a glob 607 _results = self._check_glob_minions(word, True) 608 results.append(str(set(_results["minions"]))) 609 if unmatched and unmatched[-1] == "-": 610 results.append(")") 611 unmatched.pop() 612 613 # Add a closing ')' for each item left in unmatched 614 results.extend([")" for item in unmatched]) 615 616 results = " ".join(results) 617 log.debug("Evaluating final compound matching expr: %s", results) 618 try: 619 minions = list(eval(results)) # pylint: disable=W0123 620 return {"minions": minions, "missing": missing} 621 except Exception: # pylint: disable=broad-except 622 log.error("Invalid compound target: %s", expr) 623 return {"minions": [], "missing": []} 624 625 return {"minions": list(minions), "missing": []} 626 627 def connected_ids(self, subset=None, show_ip=False): 628 """ 629 Return a set of all connected minion ids, optionally within a subset 630 """ 631 minions = set() 632 if self.opts.get("minion_data_cache", False): 633 search = self.cache.list("minions") 634 if search is None: 635 return minions 636 addrs = salt.utils.network.local_port_tcp(int(self.opts["publish_port"])) 637 if self.opts.get("detect_remote_minions", False): 638 addrs = addrs.union( 639 salt.utils.network.remote_port_tcp(self.opts["remote_minions_port"]) 640 ) 641 if "127.0.0.1" in addrs: 642 # Add in the address of a possible locally-connected minion. 643 addrs.discard("127.0.0.1") 644 addrs.update(set(salt.utils.network.ip_addrs(include_loopback=False))) 645 if "::1" in addrs: 646 # Add in the address of a possible locally-connected minion. 647 addrs.discard("::1") 648 addrs.update(set(salt.utils.network.ip_addrs6(include_loopback=False))) 649 if subset: 650 search = subset 651 for id_ in search: 652 try: 653 mdata = self.cache.fetch("minions/{}".format(id_), "data") 654 except SaltCacheError: 655 # If a SaltCacheError is explicitly raised during the fetch operation, 656 # permission was denied to open the cached data.p file. Continue on as 657 # in the releases <= 2016.3. (An explicit error raise was added in PR 658 # #35388. See issue #36867 for more information. 659 continue 660 if mdata is None: 661 continue 662 grains = mdata.get("grains", {}) 663 for ipv4 in grains.get("ipv4", []): 664 if ipv4 in addrs: 665 if show_ip: 666 minions.add((id_, ipv4)) 667 else: 668 minions.add(id_) 669 break 670 for ipv6 in grains.get("ipv6", []): 671 if ipv6 in addrs: 672 if show_ip: 673 minions.add((id_, ipv6)) 674 else: 675 minions.add(id_) 676 break 677 return minions 678 679 def _all_minions(self, expr=None): 680 """ 681 Return a list of all minions that have auth'd 682 """ 683 mlist = [] 684 for fn_ in salt.utils.data.sorted_ignorecase( 685 os.listdir(os.path.join(self.opts["pki_dir"], self.acc)) 686 ): 687 if not fn_.startswith(".") and os.path.isfile( 688 os.path.join(self.opts["pki_dir"], self.acc, fn_) 689 ): 690 mlist.append(fn_) 691 return {"minions": mlist, "missing": []} 692 693 def check_minions( 694 self, expr, tgt_type="glob", delimiter=DEFAULT_TARGET_DELIM, greedy=True 695 ): 696 """ 697 Check the passed regex against the available minions' public keys 698 stored for authentication. This should return a set of ids which 699 match the regex, this will then be used to parse the returns to 700 make sure everyone has checked back in. 701 """ 702 703 try: 704 if expr is None: 705 expr = "" 706 check_func = getattr(self, "_check_{}_minions".format(tgt_type), None) 707 if tgt_type in ( 708 "grain", 709 "grain_pcre", 710 "pillar", 711 "pillar_pcre", 712 "pillar_exact", 713 "compound", 714 "compound_pillar_exact", 715 ): 716 # pylint: disable=not-callable 717 _res = check_func(expr, delimiter, greedy) 718 # pylint: enable=not-callable 719 else: 720 _res = check_func(expr, greedy) # pylint: disable=not-callable 721 _res["ssh_minions"] = False 722 if self.opts.get("enable_ssh_minions", False) is True and isinstance( 723 "tgt", str 724 ): 725 roster = salt.roster.Roster(self.opts, self.opts.get("roster", "flat")) 726 ssh_minions = roster.targets(expr, tgt_type) 727 if ssh_minions: 728 _res["minions"].extend(ssh_minions) 729 _res["ssh_minions"] = True 730 except Exception: # pylint: disable=broad-except 731 log.exception( 732 "Failed matching available minions with %s pattern: %s", tgt_type, expr 733 ) 734 _res = {"minions": [], "missing": []} 735 return _res 736 737 def validate_tgt(self, valid, expr, tgt_type, minions=None, expr_form=None): 738 """ 739 Validate the target minions against the possible valid minions. 740 741 If ``minions`` is provided, they will be compared against the valid 742 minions. Otherwise, ``expr`` and ``tgt_type`` will be used to expand 743 to a list of target minions. 744 745 Return True if all of the requested minions are valid minions, 746 otherwise return False. 747 """ 748 749 v_minions = set(self.check_minions(valid, "compound").get("minions", [])) 750 if not v_minions: 751 # There are no valid minions, so it doesn't matter what we are 752 # targeting - this is a fail. 753 return False 754 if minions is None: 755 _res = self.check_minions(expr, tgt_type) 756 minions = set(_res["minions"]) 757 else: 758 minions = set(minions) 759 return minions.issubset(v_minions) 760 761 def match_check(self, regex, fun): 762 """ 763 Validate a single regex to function comparison, the function argument 764 can be a list of functions. It is all or nothing for a list of 765 functions 766 """ 767 vals = [] 768 if isinstance(fun, str): 769 fun = [fun] 770 for func in fun: 771 try: 772 if re.match(regex, func): 773 vals.append(True) 774 else: 775 vals.append(False) 776 except Exception: # pylint: disable=broad-except 777 log.error("Invalid regular expression: %s", regex) 778 return vals and all(vals) 779 780 def auth_check_expanded( 781 self, 782 auth_list, 783 funs, 784 args, 785 tgt, 786 tgt_type="glob", 787 groups=None, 788 publish_validate=False, 789 ): 790 791 # Here's my thinking 792 # 1. Retrieve anticipated targeted minions 793 # 2. Iterate through each entry in the auth_list 794 # 3. If it is a minion_id, check to see if any targeted minions match. 795 # If there is a match, check to make sure funs are permitted 796 # (if it's not a match we don't care about this auth entry and can 797 # move on) 798 # a. If funs are permitted, Add this minion_id to a new set of allowed minion_ids 799 # If funs are NOT permitted, can short-circuit and return FALSE 800 # b. At the end of the auth_list loop, make sure all targeted IDs 801 # are in the set of allowed minion_ids. If not, return FALSE 802 # 4. If it is a target (glob, pillar, etc), retrieve matching minions 803 # and make sure that ALL targeted minions are in the set. 804 # then check to see if the funs are permitted 805 # a. If ALL targeted minions are not in the set, then return FALSE 806 # b. If the desired fun doesn't mass the auth check with any 807 # auth_entry's fun, then return FALSE 808 809 # NOTE we are not going to try to allow functions to run on partial 810 # sets of minions. If a user targets a group of minions and does not 811 # have access to run a job on ALL of these minions then the job will 812 # fail with 'Eauth Failed'. 813 814 # The recommended workflow in that case will be for the user to narrow 815 # his target. 816 817 # This should cover adding the AD LDAP lookup functionality while 818 # preserving the existing auth behavior. 819 820 # Recommend we config-get this behind an entry called 821 # auth.enable_expanded_auth_matching 822 # and default to False 823 v_tgt_type = tgt_type 824 if tgt_type.lower() in ("pillar", "pillar_pcre"): 825 v_tgt_type = "pillar_exact" 826 elif tgt_type.lower() == "compound": 827 v_tgt_type = "compound_pillar_exact" 828 _res = self.check_minions(tgt, v_tgt_type) 829 v_minions = set(_res["minions"]) 830 831 _res = self.check_minions(tgt, tgt_type) 832 minions = set(_res["minions"]) 833 834 mismatch = bool(minions.difference(v_minions)) 835 # If the non-exact match gets more minions than the exact match 836 # then pillar globbing or PCRE is being used, and we have a 837 # problem 838 if publish_validate: 839 if mismatch: 840 return False 841 # compound commands will come in a list so treat everything as a list 842 if not isinstance(funs, list): 843 funs = [funs] 844 args = [args] 845 846 # Take the auth list and get all the minion names inside it 847 allowed_minions = set() 848 849 auth_dictionary = {} 850 851 # Make a set, so we are guaranteed to have only one of each minion 852 # Also iterate through the entire auth_list and create a dictionary 853 # so it's easy to look up what functions are permitted 854 for auth_list_entry in auth_list: 855 if isinstance(auth_list_entry, str): 856 for fun in funs: 857 # represents toplevel auth entry is a function. 858 # so this fn is permitted by all minions 859 if self.match_check(auth_list_entry, fun): 860 return True 861 continue 862 if isinstance(auth_list_entry, dict): 863 if len(auth_list_entry) != 1: 864 log.info("Malformed ACL: %s", auth_list_entry) 865 continue 866 allowed_minions.update(set(auth_list_entry.keys())) 867 for key in auth_list_entry: 868 for match in set(self.check_minions(key, "compound")): 869 if match in auth_dictionary: 870 auth_dictionary[match].extend(auth_list_entry[key]) 871 else: 872 auth_dictionary[match] = auth_list_entry[key] 873 874 allowed_minions_from_auth_list = set() 875 for next_entry in allowed_minions: 876 allowed_minions_from_auth_list.update( 877 set(self.check_minions(next_entry, "compound")) 878 ) 879 # 'minions' here are all the names of minions matched by the target 880 # if we take out all the allowed minions, and there are any left, then 881 # the target includes minions that are not allowed by eauth 882 # so we can give up here. 883 if minions - allowed_minions_from_auth_list: 884 return False 885 886 try: 887 for minion in minions: 888 results = [] 889 for num, fun in enumerate(auth_dictionary[minion]): 890 results.append(self.match_check(fun, funs)) 891 if not any(results): 892 return False 893 return True 894 895 except TypeError: 896 return False 897 return False 898 899 def auth_check( 900 self, 901 auth_list, 902 funs, 903 args, 904 tgt, 905 tgt_type="glob", 906 groups=None, 907 publish_validate=False, 908 minions=None, 909 whitelist=None, 910 ): 911 """ 912 Returns a bool which defines if the requested function is authorized. 913 Used to evaluate the standard structure under external master 914 authentication interfaces, like eauth, peer, peer_run, etc. 915 """ 916 if self.opts.get("auth.enable_expanded_auth_matching", False): 917 return self.auth_check_expanded( 918 auth_list, funs, args, tgt, tgt_type, groups, publish_validate 919 ) 920 if publish_validate: 921 v_tgt_type = tgt_type 922 if tgt_type.lower() in ("pillar", "pillar_pcre"): 923 v_tgt_type = "pillar_exact" 924 elif tgt_type.lower() == "compound": 925 v_tgt_type = "compound_pillar_exact" 926 _res = self.check_minions(tgt, v_tgt_type) 927 v_minions = set(_res["minions"]) 928 929 _res = self.check_minions(tgt, tgt_type) 930 minions = set(_res["minions"]) 931 932 mismatch = bool(minions.difference(v_minions)) 933 # If the non-exact match gets more minions than the exact match 934 # then pillar globbing or PCRE is being used, and we have a 935 # problem 936 if mismatch: 937 return False 938 # compound commands will come in a list so treat everything as a list 939 if not isinstance(funs, list): 940 funs = [funs] 941 args = [args] 942 try: 943 for num, fun in enumerate(funs): 944 if whitelist and fun in whitelist: 945 return True 946 for ind in auth_list: 947 if isinstance(ind, str): 948 # Allowed for all minions 949 if self.match_check(ind, fun): 950 return True 951 elif isinstance(ind, dict): 952 if len(ind) != 1: 953 # Invalid argument 954 continue 955 valid = next(iter(ind.keys())) 956 # Check if minions are allowed 957 if self.validate_tgt(valid, tgt, tgt_type, minions=minions): 958 # Minions are allowed, verify function in allowed list 959 fun_args = args[num] 960 fun_kwargs = fun_args[-1] if fun_args else None 961 if ( 962 isinstance(fun_kwargs, dict) 963 and "__kwarg__" in fun_kwargs 964 ): 965 fun_args = list(fun_args) # copy on modify 966 del fun_args[-1] 967 else: 968 fun_kwargs = None 969 if self.__fun_check(ind[valid], fun, fun_args, fun_kwargs): 970 return True 971 except TypeError: 972 return False 973 return False 974 975 def fill_auth_list_from_groups(self, auth_provider, user_groups, auth_list): 976 """ 977 Returns a list of authorisation matchers that a user is eligible for. 978 This list is a combination of the provided personal matchers plus the 979 matchers of any group the user is in. 980 """ 981 group_names = [item for item in auth_provider if item.endswith("%")] 982 if group_names: 983 for group_name in group_names: 984 if group_name.rstrip("%") in user_groups: 985 for matcher in auth_provider[group_name]: 986 auth_list.append(matcher) 987 return auth_list 988 989 def fill_auth_list( 990 self, auth_provider, name, groups, auth_list=None, permissive=None 991 ): 992 """ 993 Returns a list of authorisation matchers that a user is eligible for. 994 This list is a combination of the provided personal matchers plus the 995 matchers of any group the user is in. 996 """ 997 if auth_list is None: 998 auth_list = [] 999 if permissive is None: 1000 permissive = self.opts.get("permissive_acl") 1001 name_matched = False 1002 for match in auth_provider: 1003 if match == "*" and not permissive: 1004 continue 1005 if match.endswith("%"): 1006 if match.rstrip("%") in groups: 1007 auth_list.extend(auth_provider[match]) 1008 else: 1009 if salt.utils.stringutils.expr_match(match, name): 1010 name_matched = True 1011 auth_list.extend(auth_provider[match]) 1012 if not permissive and not name_matched and "*" in auth_provider: 1013 auth_list.extend(auth_provider["*"]) 1014 return auth_list 1015 1016 def wheel_check(self, auth_list, fun, args): 1017 """ 1018 Check special API permissions 1019 """ 1020 return self.spec_check(auth_list, fun, args, "wheel") 1021 1022 def runner_check(self, auth_list, fun, args): 1023 """ 1024 Check special API permissions 1025 """ 1026 return self.spec_check(auth_list, fun, args, "runner") 1027 1028 def spec_check(self, auth_list, fun, args, form): 1029 """ 1030 Check special API permissions 1031 """ 1032 if not auth_list: 1033 return False 1034 if form != "cloud": 1035 comps = fun.split(".") 1036 if len(comps) != 2: 1037 # Hint at a syntax error when command is passed improperly, 1038 # rather than returning an authentication error of some kind. 1039 # See Issue #21969 for more information. 1040 return { 1041 "error": { 1042 "name": "SaltInvocationError", 1043 "message": "A command invocation error occurred: Check syntax.", 1044 } 1045 } 1046 mod_name = comps[0] 1047 fun_name = comps[1] 1048 else: 1049 fun_name = mod_name = fun 1050 for ind in auth_list: 1051 if isinstance(ind, str): 1052 if ind[0] == "@": 1053 if ( 1054 ind[1:] == mod_name 1055 or ind[1:] == form 1056 or ind == "@{}s".format(form) 1057 ): 1058 return True 1059 elif isinstance(ind, dict): 1060 if len(ind) != 1: 1061 continue 1062 valid = next(iter(ind.keys())) 1063 if valid[0] == "@": 1064 if valid[1:] == mod_name: 1065 if self.__fun_check( 1066 ind[valid], fun_name, args.get("arg"), args.get("kwarg") 1067 ): 1068 return True 1069 if valid[1:] == form or valid == "@{}s".format(form): 1070 if self.__fun_check( 1071 ind[valid], fun, args.get("arg"), args.get("kwarg") 1072 ): 1073 return True 1074 return False 1075 1076 def __fun_check(self, valid, fun, args=None, kwargs=None): 1077 """ 1078 Check the given function name (fun) and its arguments (args) against the list of conditions. 1079 """ 1080 if not isinstance(valid, list): 1081 valid = [valid] 1082 for cond in valid: 1083 # Function name match 1084 if isinstance(cond, str): 1085 if self.match_check(cond, fun): 1086 return True 1087 # Function and args match 1088 elif isinstance(cond, dict): 1089 if len(cond) != 1: 1090 # Invalid argument 1091 continue 1092 fname_cond = next(iter(cond.keys())) 1093 if self.match_check( 1094 fname_cond, fun 1095 ): # check key that is function name match 1096 if self.__args_check(cond[fname_cond], args, kwargs): 1097 return True 1098 return False 1099 1100 def __args_check(self, valid, args=None, kwargs=None): 1101 """ 1102 valid is a dicts: {'args': [...], 'kwargs': {...}} or a list of such dicts. 1103 """ 1104 if not isinstance(valid, list): 1105 valid = [valid] 1106 for cond in valid: 1107 if not isinstance(cond, dict): 1108 # Invalid argument 1109 continue 1110 # whitelist args, kwargs 1111 cond_args = cond.get("args", []) 1112 good = True 1113 for i, cond_arg in enumerate(cond_args): 1114 if args is None or len(args) <= i: 1115 good = False 1116 break 1117 if cond_arg is None: # None == '.*' i.e. allow any 1118 continue 1119 if not self.match_check(cond_arg, str(args[i])): 1120 good = False 1121 break 1122 if not good: 1123 continue 1124 # Check kwargs 1125 cond_kwargs = cond.get("kwargs", {}) 1126 for k, v in cond_kwargs.items(): 1127 if kwargs is None or k not in kwargs: 1128 good = False 1129 break 1130 if v is None: # None == '.*' i.e. allow any 1131 continue 1132 if not self.match_check(v, str(kwargs[k])): 1133 good = False 1134 break 1135 if good: 1136 return True 1137 return False 1138