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