nacm.py 15.2 KB
Newer Older
1
import collections
2
from io import StringIO
3
from threading import Lock
Pavel Spirek's avatar
Pavel Spirek committed
4
from enum import Enum
5
from colorlog import error, info
Pavel Spirek's avatar
Pavel Spirek committed
6
from typing import List, Set, Optional
7

Pavel Spirek's avatar
Pavel Spirek committed
8 9 10 11 12 13 14 15 16 17
from yangson.instance import (
    InstanceNode,
    NonexistentSchemaNode,
    NonexistentInstance,
    ArrayValue,
    ObjectValue,
    InstanceSelector,
    InstanceRoute,
    MemberName,
    EntryIndex,
18
    EntryKeys
Pavel Spirek's avatar
Pavel Spirek committed
19
)
20

21
from .helpers import DataHelpers, ErrorHelpers, LogHelpers, PathFormat
Pavel Spirek's avatar
Pavel Spirek committed
22 23

epretty = ErrorHelpers.epretty
24
debug_nacm = LogHelpers.create_module_dbg_logger(__name__)
25

26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46

class Action(Enum):
    PERMIT = True
    DENY = False


class Permission(Enum):
    NACM_ACCESS_READ = 0
    NACM_ACCESS_CREATE = 1
    NACM_ACCESS_UPDATE = 2
    NACM_ACCESS_DELETE = 3
    NACM_ACCESS_EXEC = 4


class NacmRuleType(Enum):
    NACM_RULE_NOTSET = 0
    NACM_RULE_OPERATION = 1
    NACM_RULE_NOTIF = 2
    NACM_RULE_DATA = 3


47 48 49 50 51 52 53 54
class NonexistentUserError(Exception):
    def __init__(self, msg=""):
        self.msg = msg

    def __str__(self):
        return self.msg


55 56 57 58 59 60 61 62 63
class NacmGroup:
    def __init__(self, name: str, users: List[str]):
        self.name = name
        self.users = users


class NacmRule:
    class TypeData:
        def __init__(self):
64 65 66
            self.path = None        # type: str
            self.rpc_names = None   # type: List[str]
            self.ntf_names = None   # type: List[str]
67 68

    def __init__(self):
69 70 71 72
        self.name = None                            # type: str
        self.comment = None                         # type: str
        self.module = None                          # type: str
        self.type = NacmRuleType.NACM_RULE_NOTSET   # type: NacmRuleType
73
        self.type_data = self.TypeData()
74
        self.access = set()                         # type: Set[Permission]
75 76 77
        self.action = Action.DENY


78 79 80 81 82 83 84
class RuleTreeNode:
    def __init__(self, isel: InstanceSelector=None, up: "RuleTreeNode"=None):
        self.isel = isel
        self.rule = None    # type: NacmRule
        self.up = up
        self.children = []  # type: List[RuleTreeNode]

Pavel Spirek's avatar
Pavel Spirek committed
85
    def get_rule(self, perm: Permission) -> Optional[NacmRule]:
86 87 88
        n = self
        while n:
            if (n.rule is not None) and (perm in n.rule.access):
89
                return n.rule
90 91 92 93
            n = n.up

        return None

Pavel Spirek's avatar
Pavel Spirek committed
94
    def get_action(self, perm: Permission) -> Optional[Action]:
95 96 97
        rule = self.get_rule(perm)
        return rule.action if rule is not None else None

98

99 100
class NacmRuleList:
    def __init__(self):
101 102 103
        self.name = ""          # type: str
        self.groups = []        # type: List[NacmGroup]
        self.rules = []         # type: List[NacmRule]
104 105 106


class DataRuleTree:
107
    def __init__(self, rule_lists: List[NacmRuleList]):
108 109 110 111
        self.root = []  # type: List[RuleTreeNode]

        for rl in rule_lists:
            for rule in filter(lambda r: r.type == NacmRuleType.NACM_RULE_DATA, rl.rules):
Pavel Spirek's avatar
Pavel Spirek committed
112 113 114 115 116
                try:
                    ii = DataHelpers.parse_ii(rule.type_data.path, PathFormat.XPATH)
                except NonexistentSchemaNode as e:
                    error(epretty(e, __name__))
                    ii = []
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
                nl = self.root
                node_match_prev = None
                for isel in ii:
                    node_match = (list(filter(lambda x: x.isel == isel, nl)) or [None])[0]
                    if node_match is None:
                        new_elem = RuleTreeNode()
                        new_elem.isel = isel
                        new_elem.up = node_match_prev

                        if isel is ii[-1]:
                            new_elem.rule = rule
                        nl.append(new_elem)
                        node_match_prev = new_elem
                        nl = new_elem.children
                    else:
                        if isel is ii[-1]:
                            node_match.rule = rule
                        node_match_prev = node_match
                        nl = node_match.children
136

137
    def _print_rule_tree(self, io_str: StringIO, rule_node_list: List[RuleTreeNode], depth: int, vbars: List[int]):
Pavel Spirek's avatar
Pavel Spirek committed
138
        indent_str_list = list(("   " * depth) + "+--")
139
        for vb in vbars:
Pavel Spirek's avatar
Pavel Spirek committed
140 141
            indent_str_list[vb * 3] = "|"
        indent_str = "".join(indent_str_list)
142 143

        for rule_node in rule_node_list:
Pavel Spirek's avatar
Pavel Spirek committed
144 145 146 147 148 149 150
            rule = rule_node.rule
            if rule is not None:
                action_str = rule.action.name
                access = sorted(list(map(lambda n: n.name.split("_")[-1].lower(), rule.access)))
                io_str.write(indent_str + " " + str(rule_node.isel) + " " + action_str + str(access) + "\n")
            else:
                io_str.write(indent_str + " " + str(rule_node.isel) + "\n")
151
            if rule_node is rule_node_list[-1]:
152
                self._print_rule_tree(io_str, rule_node.children, depth + 1, vbars)
153
            else:
154
                self._print_rule_tree(io_str, rule_node.children, depth + 1, vbars + [depth])
155

156 157 158 159 160
    def print_rule_tree(self) -> str:
        io_str = StringIO()
        io_str.write("----- NACM Data Rule tree -----\n")
        self._print_rule_tree(io_str, self.root, 0, [])
        return io_str.getvalue()
161 162 163


class NacmConfig:
Pavel Spirek's avatar
Pavel Spirek committed
164
    def __init__(self, nacm_ds: "BaseDatastore"):
Pavel Spirek's avatar
Pavel Spirek committed
165
        self.nacm_ds = nacm_ds
166 167 168 169 170 171
        self.enabled = False
        self.default_read = Action.PERMIT
        self.default_write = Action.PERMIT
        self.default_exec = Action.PERMIT
        self.nacm_groups = []
        self.rule_lists = []
172
        self._user_nacm_rpc = {}
173
        self.internal_data_lock = Lock()
174
        self._lock_username = None
Pavel Spirek's avatar
pokus  
Pavel Spirek committed
175 176

    # Fills internal read-only data structures
177
    def update(self):
178 179 180 181 182 183 184
        lock_res = self.internal_data_lock.acquire(blocking=True, timeout=1)
        if not lock_res:
            error("NACM update: cannot acquire data lock")
            return

        self.nacm_groups = []
        self.rule_lists = []
185
        self._user_nacm_rpc = {}
186

187
        try:
188
            nacm_json = self.nacm_ds.get_data_root()["ietf-netconf-acm:nacm"].value
189 190
        except NonexistentInstance:
            raise ValueError("Data does not contain \"ietf-netconf-acm:nacm\" root element")
191

192
        self.enabled = nacm_json["enable-nacm"]
193
        if not self.enabled:
194
            # NACM not enabled, no need to continue
195 196 197
            self.internal_data_lock.release()
            return

Pavel Spirek's avatar
pokus  
Pavel Spirek committed
198 199 200 201 202
        self.default_read = Action.PERMIT if nacm_json["read-default"] == "permit" else Action.DENY
        self.default_write = Action.PERMIT if nacm_json["write-default"] == "permit" else Action.DENY
        self.default_exec = Action.PERMIT if nacm_json["exec-default"] == "permit" else Action.DENY

        for group in nacm_json["groups"]["group"]:
203 204
            self.nacm_groups.append(NacmGroup(group["name"], group["user-name"]))

Pavel Spirek's avatar
pokus  
Pavel Spirek committed
205
        for rule_list_json in nacm_json["rule-list"]:
206 207 208 209 210 211 212 213 214 215 216
            rl = NacmRuleList()
            rl.name = rule_list_json["name"]
            rl.groups = rule_list_json["group"]

            for rule_json in rule_list_json["rule"]:
                rule = NacmRule()
                rule.name = rule_json.get("name")
                rule.comment = rule_json.get("comment")
                rule.module = rule_json.get("module-name")

                if rule_json.get("access-operations") is not None:
217 218 219 220 221 222 223 224 225 226 227 228 229
                    access_perm_list = rule_json["access-operations"]
                    if isinstance(access_perm_list, str) and (access_perm_list == "*"):
                        rule.access = set(Permission)
                    elif isinstance(access_perm_list, collections.Iterable):
                        def perm_str2enum(perm_str: str):
                            return {
                                "read": Permission.NACM_ACCESS_READ,
                                "create": Permission.NACM_ACCESS_CREATE,
                                "update": Permission.NACM_ACCESS_UPDATE,
                                "delete": Permission.NACM_ACCESS_DELETE,
                                "exec": Permission.NACM_ACCESS_EXEC,
                            }.get(perm_str)
                        rule.access.update(map(perm_str2enum, access_perm_list))
230 231 232

                if rule_json.get("rpc-name") is not None:
                    if rule.type != NacmRuleType.NACM_RULE_NOTSET:
233
                        error("Invalid rule definition (multiple cases from rule-type choice): \"{}\"".format(rule.name))
234 235 236 237 238 239
                    else:
                        rule.type = NacmRuleType.NACM_RULE_OPERATION
                        rule.type_data.rpc_names = rule_json.get("rpc-name").split()

                if rule_json.get("notification-name") is not None:
                    if rule.type != NacmRuleType.NACM_RULE_NOTSET:
240
                        error("Invalid rule definition (multiple cases from rule-type choice): \"{}\"".format(rule.name))
241 242 243 244 245 246
                    else:
                        rule.type = NacmRuleType.NACM_RULE_NOTIF
                        rule.type_data.ntf_names = rule_json.get("notification-name").split()

                if rule_json.get("path") is not None:
                    if rule.type != NacmRuleType.NACM_RULE_NOTSET:
247
                        error("Invalid rule definition (multiple cases from rule-type choice): \"{}\"".format(rule.name))
248 249
                    else:
                        rule.type = NacmRuleType.NACM_RULE_DATA
Pavel Spirek's avatar
Pavel Spirek committed
250
                        rule.type_data.path = rule_json["path"]
251 252 253 254 255 256

                rule.action = Action.PERMIT if rule_json["action"] == "permit" else Action.DENY
                rl.rules.append(rule)

            self.rule_lists.append(rl)

257 258
        self.internal_data_lock.release()

259 260 261 262 263 264 265 266 267 268 269 270
    def create_user_nacm(self, username: str):
        # all_users = set()
        # for gr in self.nacm_groups:
        #     for user in gr.users:
        #         all_users.add(user)

        # for user in all_users:
        #     info("Creating personalized rule list for user \"{}\"".format(user))
        #     self._user_nacm_rpc[user] = UserNacm(self, user)
        # if username not in all_users:
        #     raise NonexistentUserError

271 272 273 274
        if not self.internal_data_lock.acquire(blocking=True, timeout=1):
            error("Cannot acquire NACM config lock ")
            return

275 276 277
        info("Creating personalized rule list for user \"{}\"".format(username))
        self._user_nacm_rpc[username] = UserNacm(self, username)

278 279
        self.internal_data_lock.release()

280 281 282 283 284 285 286 287
    def get_user_nacm(self, username: str) -> "UserNacm":
        user_nacm = self._user_nacm_rpc.get(username)
        if user_nacm is None:
            self.create_user_nacm(username)
            user_nacm = self._user_nacm_rpc.get(username)

        return user_nacm

288

289 290 291
# Rules for particular user
class UserNacm:
    def __init__(self, config: NacmConfig, username: str):
292
        self.nacm_enabled = config.enabled
293 294 295
        self.default_read = config.default_read
        self.default_write = config.default_write
        self.default_exec = config.default_exec
296 297
        self.rule_lists = []

298 299
        user_groups = list(filter(lambda x: username in x.users, config.nacm_groups))
        user_groups_names = list(map(lambda x: x.name, user_groups))
300 301
        self.rule_lists = list(filter(lambda x: (set(user_groups_names) & set(x.groups)), config.rule_lists))

302 303
        self.rule_tree = DataRuleTree(self.rule_lists)
        debug_nacm("Rule tree for user \"{}\":\n{}".format(username, self.rule_tree.print_rule_tree()))
Pavel Spirek's avatar
Pavel Spirek committed
304

305
    def check_data_node_permission(self, root: InstanceNode, ii: InstanceRoute, access: Permission) -> Action:
306 307 308
        if not self.nacm_enabled:
            return Action.PERMIT

Pavel Spirek's avatar
Pavel Spirek committed
309
        data_node_value = root.value    # type: InstanceNode
310

Pavel Spirek's avatar
Pavel Spirek committed
311 312
        nl = self.rule_tree.root        # type: List[RuleTreeNode]
        node_match = None               # type: RuleTreeNode
313
        for isel in ii:
Pavel Spirek's avatar
Pavel Spirek committed
314 315
            # Find child by instance selector
            node_match_step = None      # type: RuleTreeNode
316 317
            for rule_node in nl:
                if (type(rule_node.isel) == type(isel)) and (rule_node.isel == isel):
Pavel Spirek's avatar
Pavel Spirek committed
318
                    node_match_step = rule_node
319
                    break
320

Pavel Spirek's avatar
Pavel Spirek committed
321 322 323 324
                if isinstance(isel, EntryIndex) and isinstance(rule_node.isel, EntryKeys) and \
                        (isel.peek_step(data_node_value) is rule_node.isel.peek_step(data_node_value)):
                    node_match_step = rule_node
                    break
325

Pavel Spirek's avatar
Pavel Spirek committed
326 327 328 329
            if node_match_step:
                nl = node_match_step.children
                node_match = node_match_step
                data_node_value = isel.peek_step(data_node_value)
330 331
            else:
                break
332

Pavel Spirek's avatar
Pavel Spirek committed
333 334 335 336 337 338
        if node_match is not None:
            # Matching rule found
            retval = node_match.get_action(access)
        else:
            # No matching rule, return default action
            retval = self.default_read if access == Permission.NACM_ACCESS_READ else self.default_write
339

Pavel Spirek's avatar
Pavel Spirek committed
340
        # debug_nacm("check_data_node_path, result = {}".format(retval.name))
341 342
        return retval

343
    def _prune_data_tree(self, node: InstanceNode, root: InstanceNode, ii: InstanceRoute, access: Permission) -> InstanceNode:
344 345
        if isinstance(node.value, ObjectValue):
            # print("obj: {}".format(node.value))
Pavel Spirek's avatar
Pavel Spirek committed
346 347 348
            nsel = MemberName("")
            mii = ii + [nsel]
            for child_key in node.value.keys():
349
                nsel.key = child_key
350 351
                m = nsel.goto_step(node)

Pavel Spirek's avatar
Pavel Spirek committed
352
                # debug_nacm("checking mii {}".format(mii))
353
                if self.check_data_node_permission(root, mii, access) == Action.DENY:
354
                    # debug_nacm("Pruning node {} {}".format(id(node.value[child_key]), node.value[child_key]))
Pavel Spirek's avatar
Pavel Spirek committed
355
                    debug_nacm("Pruning node {}".format(DataHelpers.ii2str(mii)))
356
                    node = node.delete_item(child_key)
357
                else:
358
                    node = self._prune_data_tree(m, root, mii, access).up()
359 360
        elif isinstance(node.value, ArrayValue):
            # print("array: {}".format(node.value))
Pavel Spirek's avatar
Pavel Spirek committed
361 362
            nsel = EntryIndex(0)
            eii = ii + [nsel]
363 364 365
            i = 0
            arr_len = len(node.value)
            while i < arr_len:
Pavel Spirek's avatar
Pavel Spirek committed
366
                nsel.index = i
367 368
                e = nsel.goto_step(node)

Pavel Spirek's avatar
Pavel Spirek committed
369
                # debug_nacm("checking eii {}".format(eii))
370
                if self.check_data_node_permission(root, eii, access) == Action.DENY:
371
                    # debug_nacm("Pruning node {} {}".format(id(node.value[i]), node.value[i]))
Pavel Spirek's avatar
Pavel Spirek committed
372
                    debug_nacm("Pruning node {}".format(DataHelpers.ii2str(eii)))
373
                    node = node.delete_item(i)
374 375 376
                    arr_len -= 1
                else:
                    i += 1
377
                    node = self._prune_data_tree(e, root, eii, access).up()
378 379 380

        return node

381
    def prune_data_tree(self, node: InstanceNode, root: InstanceNode, ii: InstanceRoute, access: Permission) -> InstanceNode:
382
        if not self.nacm_enabled:
383
            return node
384
        else:
385
            return self._prune_data_tree(node, root, ii, access)
386

Pavel Spirek's avatar
Pavel Spirek committed
387
    def check_rpc_name(self, rpc_name: str) -> Action:
388 389 390 391 392 393 394 395 396
        if not self.nacm_enabled:
            return Action.PERMIT

        for rl in self.rule_lists:
            for rpc_rule in filter(lambda r: r.type == NacmRuleType.NACM_RULE_OPERATION, rl.rules):
                if rpc_name in rpc_rule.type_data.rpc_names:
                    return rpc_rule.action

        return self.default_exec