nacm.py 15.8 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
Pavel Spirek's avatar
Pavel Spirek committed
5 6
from typing import Union

7
from colorlog import error, warning as warn, info
Pavel Spirek's avatar
Pavel Spirek committed
8
from typing import List, Set, Optional
9

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

Pavel Spirek's avatar
Pavel Spirek committed
22 23
from .helpers import DataHelpers, ErrorHelpers, LogHelpers
from .errors import JetconfError
Pavel Spirek's avatar
Pavel Spirek committed
24 25

epretty = ErrorHelpers.epretty
26
debug_nacm = LogHelpers.create_module_dbg_logger(__name__)
27

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

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


Pavel Spirek's avatar
Pavel Spirek committed
49 50 51 52 53 54 55 56 57 58
class NacmError(JetconfError):
    pass


class NonexistentUserError(NacmError):
    pass


class NacmForbiddenError(NacmError):
    def __init__(self, msg="Access to data node rejected by NACM", rule=None):
59
        self.msg = msg
Pavel Spirek's avatar
Pavel Spirek committed
60
        self.rule = rule
61 62

    def __str__(self):
Pavel Spirek's avatar
Pavel Spirek committed
63
        return "{} (rule: {})".format(self.msg, str(self.rule))
64 65


66 67 68 69 70 71 72 73 74
class NacmGroup:
    def __init__(self, name: str, users: List[str]):
        self.name = name
        self.users = users


class NacmRule:
    class TypeData:
        def __init__(self):
75 76 77
            self.path = None        # type: str
            self.rpc_names = None   # type: List[str]
            self.ntf_names = None   # type: List[str]
78 79

    def __init__(self):
80 81 82 83
        self.name = None                            # type: str
        self.comment = None                         # type: str
        self.module = None                          # type: str
        self.type = NacmRuleType.NACM_RULE_NOTSET   # type: NacmRuleType
84
        self.type_data = self.TypeData()
85
        self.access = set()                         # type: Set[Permission]
86 87 88
        self.action = Action.DENY


89
class RuleTreeNode:
90
    def __init__(self, isel=None, up: "RuleTreeNode"=None):
91 92 93 94 95
        self.isel = isel
        self.rule = None    # type: NacmRule
        self.up = up
        self.children = []  # type: List[RuleTreeNode]

Pavel Spirek's avatar
Pavel Spirek committed
96
    def get_rule(self, perm: Permission) -> Optional[NacmRule]:
97 98 99
        n = self
        while n:
            if (n.rule is not None) and (perm in n.rule.access):
100
                return n.rule
101 102 103 104
            n = n.up

        return None

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

109

110 111
class NacmRuleList:
    def __init__(self):
112 113 114
        self.name = ""          # type: str
        self.groups = []        # type: List[NacmGroup]
        self.rules = []         # type: List[NacmRule]
115 116 117


class DataRuleTree:
118
    def __init__(self, dm: DataModel, rule_lists: List[NacmRuleList]):
119 120 121 122
        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
123
                try:
124
                    ii = dm.parse_instance_id(rule.type_data.path)
Pavel Spirek's avatar
Pavel Spirek committed
125 126 127
                except NonexistentSchemaNode as e:
                    error(epretty(e, __name__))
                    ii = []
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146
                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
147

148
    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
149
        indent_str_list = list(("   " * depth) + "+--")
150
        for vb in vbars:
Pavel Spirek's avatar
Pavel Spirek committed
151 152
            indent_str_list[vb * 3] = "|"
        indent_str = "".join(indent_str_list)
153 154

        for rule_node in rule_node_list:
Pavel Spirek's avatar
Pavel Spirek committed
155 156 157 158 159 160 161
            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")
162
            if rule_node is rule_node_list[-1]:
163
                self._print_rule_tree(io_str, rule_node.children, depth + 1, vbars)
164
            else:
165
                self._print_rule_tree(io_str, rule_node.children, depth + 1, vbars + [depth])
166

167 168 169 170 171
    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()
172 173 174


class NacmConfig:
175
    def __init__(self, nacm_ds: "BaseDatastore", dm: DataModel):
Pavel Spirek's avatar
Pavel Spirek committed
176
        self.nacm_ds = nacm_ds
177
        self.dm = dm
178 179 180 181 182 183
        self.enabled = False
        self.default_read = Action.PERMIT
        self.default_write = Action.PERMIT
        self.default_exec = Action.PERMIT
        self.nacm_groups = []
        self.rule_lists = []
184
        self._user_nacm_rpc = {}
185
        self.internal_data_lock = Lock()
186
        self._lock_username = None
Pavel Spirek's avatar
pokus  
Pavel Spirek committed
187 188

    # Fills internal read-only data structures
189
    def update(self):
190 191 192 193 194 195 196
        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 = []
197
        self._user_nacm_rpc = {}
198

199
        try:
200
            nacm_json = self.nacm_ds.get_data_root()["ietf-netconf-acm:nacm"].value
201
        except NonexistentInstance:
202 203
            warn("Data does not contain \"ietf-netconf-acm:nacm\" node, NACM rules will be empty")
            return
204

205
        self.enabled = nacm_json["enable-nacm"]
206
        if not self.enabled:
207
            # NACM not enabled, no need to continue
208 209 210
            self.internal_data_lock.release()
            return

Pavel Spirek's avatar
pokus  
Pavel Spirek committed
211 212 213 214 215
        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"]:
216 217
            self.nacm_groups.append(NacmGroup(group["name"], group["user-name"]))

Pavel Spirek's avatar
pokus  
Pavel Spirek committed
218
        for rule_list_json in nacm_json["rule-list"]:
219 220 221 222 223 224 225 226 227 228 229
            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:
230 231 232 233 234 235 236 237 238 239 240 241 242
                    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))
243 244 245

                if rule_json.get("rpc-name") is not None:
                    if rule.type != NacmRuleType.NACM_RULE_NOTSET:
246
                        error("Invalid rule definition (multiple cases from rule-type choice): \"{}\"".format(rule.name))
247 248 249 250 251 252
                    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:
253
                        error("Invalid rule definition (multiple cases from rule-type choice): \"{}\"".format(rule.name))
254 255 256 257 258 259
                    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:
260
                        error("Invalid rule definition (multiple cases from rule-type choice): \"{}\"".format(rule.name))
261 262
                    else:
                        rule.type = NacmRuleType.NACM_RULE_DATA
Pavel Spirek's avatar
Pavel Spirek committed
263
                        rule.type_data.path = rule_json["path"]
264 265 266 267 268 269

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

            self.rule_lists.append(rl)

270 271
        self.internal_data_lock.release()

272 273 274 275 276 277 278 279 280 281 282 283
    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

284 285 286 287
        if not self.internal_data_lock.acquire(blocking=True, timeout=1):
            error("Cannot acquire NACM config lock ")
            return

288
        info("Creating personalized rule list for user \"{}\"".format(username))
Pavel Spirek's avatar
Pavel Spirek committed
289
        self._user_nacm_rpc[username] = UserRuleSet(self.dm, self, username)
290

291 292
        self.internal_data_lock.release()

Pavel Spirek's avatar
Pavel Spirek committed
293
    def get_user_rules(self, username: str) -> "UserRuleSet":
294 295 296 297 298 299 300
        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

301

302
# Rules for particular user
Pavel Spirek's avatar
Pavel Spirek committed
303
class UserRuleSet:
304
    def __init__(self, dm: DataModel, config: NacmConfig, username: str):
305
        self.nacm_enabled = config.enabled
306 307 308
        self.default_read = config.default_read
        self.default_write = config.default_write
        self.default_exec = config.default_exec
309 310
        self.rule_lists = []

311 312
        user_groups = list(filter(lambda x: username in x.users, config.nacm_groups))
        user_groups_names = list(map(lambda x: x.name, user_groups))
313 314
        self.rule_lists = list(filter(lambda x: (set(user_groups_names) & set(x.groups)), config.rule_lists))

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

318
    def check_data_node_permission(self, root: InstanceNode, ii: InstanceRoute, access: Permission) -> Action:
319 320 321
        if not self.nacm_enabled:
            return Action.PERMIT

322
        data_node_value = (root.value, root.schema_node)
323

Pavel Spirek's avatar
Pavel Spirek committed
324 325
        nl = self.rule_tree.root        # type: List[RuleTreeNode]
        node_match = None               # type: RuleTreeNode
326
        for isel in ii:
Pavel Spirek's avatar
Pavel Spirek committed
327 328
            # Find child by instance selector
            node_match_step = None      # type: RuleTreeNode
329 330
            for rule_node in nl:
                if (type(rule_node.isel) == type(isel)) and (rule_node.isel == isel):
Pavel Spirek's avatar
Pavel Spirek committed
331
                    node_match_step = rule_node
332
                    break
333

Pavel Spirek's avatar
Pavel Spirek committed
334
                if isinstance(isel, EntryIndex) and isinstance(rule_node.isel, EntryKeys) and \
335
                        (isel.peek_step(*data_node_value)[0] is rule_node.isel.peek_step(*data_node_value)[0]):
Pavel Spirek's avatar
Pavel Spirek committed
336 337
                    node_match_step = rule_node
                    break
338

Pavel Spirek's avatar
Pavel Spirek committed
339 340 341
            if node_match_step:
                nl = node_match_step.children
                node_match = node_match_step
342
                data_node_value = isel.peek_step(*data_node_value)
343 344
            else:
                break
345

Pavel Spirek's avatar
Pavel Spirek committed
346 347 348 349 350 351
        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
352

Pavel Spirek's avatar
Pavel Spirek committed
353
        # debug_nacm("check_data_node_path, result = {}".format(retval.name))
354 355
        return retval

356
    def _prune_data_tree(self, node: InstanceNode, root: InstanceNode, ii: InstanceRoute, access: Permission) -> InstanceNode:
357 358
        if isinstance(node.value, ObjectValue):
            # print("obj: {}".format(node.value))
Pavel Spirek's avatar
Pavel Spirek committed
359
            nsel = MemberName(name="", ns=None)
Pavel Spirek's avatar
Pavel Spirek committed
360 361
            mii = ii + [nsel]
            for child_key in node.value.keys():
Pavel Spirek's avatar
Pavel Spirek committed
362 363 364 365 366
                key_splitted = child_key.split(":", maxsplit=1)
                if len(key_splitted) > 1:
                    nsel.namespace, nsel.name = key_splitted
                else:
                    nsel.namespace, nsel.name = (None, key_splitted[0])
367 368
                m = nsel.goto_step(node)

Pavel Spirek's avatar
Pavel Spirek committed
369
                # debug_nacm("checking mii {}".format(mii))
370
                if self.check_data_node_permission(root, mii, access) == Action.DENY:
371
                    # debug_nacm("Pruning node {} {}".format(id(node.value[child_key]), node.value[child_key]))
372
                    debug_nacm("Pruning node {}".format(DataHelpers.ii2str(mii)))
373
                    node = node.delete_item(child_key)
374
                else:
375
                    node = self._prune_data_tree(m, root, mii, access).up()
376 377
        elif isinstance(node.value, ArrayValue):
            # print("array: {}".format(node.value))
Pavel Spirek's avatar
Pavel Spirek committed
378 379
            nsel = EntryIndex(0)
            eii = ii + [nsel]
380 381 382
            i = 0
            arr_len = len(node.value)
            while i < arr_len:
Pavel Spirek's avatar
Pavel Spirek committed
383
                nsel.index = i
384 385
                e = nsel.goto_step(node)

Pavel Spirek's avatar
Pavel Spirek committed
386
                # debug_nacm("checking eii {}".format(eii))
387
                if self.check_data_node_permission(root, eii, access) == Action.DENY:
388
                    # debug_nacm("Pruning node {} {}".format(id(node.value[i]), node.value[i]))
389
                    debug_nacm("Pruning node {}".format(DataHelpers.ii2str(eii)))
390
                    node = node.delete_item(i)
391 392 393
                    arr_len -= 1
                else:
                    i += 1
394
                    node = self._prune_data_tree(e, root, eii, access).up()
395 396 397

        return node

398
    def prune_data_tree(self, node: InstanceNode, root: InstanceNode, ii: InstanceRoute, access: Permission) -> InstanceNode:
399
        if not self.nacm_enabled:
400
            return node
401
        else:
402
            return self._prune_data_tree(node, root, ii, access)
403

404
    def check_rpc_name(self, rpc_name: str) -> Action:
405 406 407 408 409 410 411 412 413
        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