Commit 8a4793e0 authored by Peter V. Saveliev's avatar Peter V. Saveliev

ndb: authentication plugins API

parent c714f4d5
......@@ -92,5 +92,6 @@ Reference
ndb_schema
ndb_sources
ndb_debug
ndb_auth
*work in progress*
.. _ndbauth:
Authorization plugins
=====================
.. automodule:: pyroute2.ndb.auth_manager
.. literalinclude:: ../examples/ndb/keystone_auth.py
:language: python
'''
A simplest example of a custom AuthManager and its usage
with `AuthProxy` objects.
Here we authenticate the auth token against Keystone and
allow any NDB operations until it is expired.
One can get such token with a curl request::
$ cat request.json
{ "auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": "admin",
"domain": { "name": "admin_domain" },
"password": "secret"
}
}
},
"scope": {
"project": {
"id": "f0af12d451fb4bccbb38217e7f9afe9a"
}
}
}
}
$ curl -i \
-H "Content-Type: application/json" \
-d "@request.json" \
http://keystone:5000/v3/auth/tokens
`X-Subject-Token` header in the response will be the token we need. Say we
get `14080769fe05e1f8b837fb43ca0f0ba4` as `X-Subject-Token`. Then you can
run::
$ . openstack.rc # <-- your OpenStack APIv3 RC file
$ export PYTHONPATH=`pwd`
$ python examples/ndb/keystone_auth.py 14080769fe05e1f8b837fb43ca0f0ba4
Using this example you can implement services that export NDB via any RPC,
e.g. HTTP, and use Keystone integration. Same scheme may be used for any
other Auth API, be it RADIUS or like that.
An example of a simple HTTP service you can find in /cli/pyroute2-cli.
'''
import os
import sys
import time
from dateutil.parser import parse as isodate
from keystoneauth1.identity import v3
from keystoneauth1 import session
from keystoneclient.v3 import client as ksclient
from keystoneclient.v3.tokens import TokenManager
from pyroute2 import NDB
class OSAuthManager(object):
def __init__(self, token, log):
# create a Keystone password object
auth = v3.Password(auth_url=os.environ.get('OS_AUTH_URL'),
username=os.environ.get('OS_USERNAME'),
password=os.environ.get('OS_PASSWORD'),
user_domain_name=(os
.environ
.get('OS_USER_DOMAIN_NAME')),
project_id=os.environ.get('OS_PROJECT_ID'))
# create a session object
sess = session.Session(auth=auth)
# create a token manager
tmanager = TokenManager(ksclient.Client(session=sess))
# validate the token
keystone_response = tmanager.validate(token)
# init attrs
self.log = log
self.expire = isodate(keystone_response['expires_at']).timestamp()
def check(self, obj, tag):
#
# totally ignore obj and tag, validate only token expiration
#
# problems to be solved before you use this code in production:
# 1. access levels: read-only, read-write -- match tag
# 2. how to deal with revoked tokens
#
if time.time() > self.expire:
self.log.error('%s permission denied' % (tag, ))
raise PermissionError('keystone token has been expired')
self.log.info('%s permission granted' % (tag, ))
return True
with NDB(log='debug') as ndb:
# create a utility log channel
log = ndb.log.channel('main')
# create an AuthManager-compatible object
log.info('request keystone auth')
am = OSAuthManager(sys.argv[1], ndb.log.channel('keystone'))
log.info('keystone auth complete, expires %s' % am.expire)
# create an auth proxy for this particular token
ap = ndb.auth_proxy(am)
# validate access via that proxy
print(ap.interfaces['lo'])
......@@ -17,14 +17,25 @@ import threading
log = logging.getLogger(__name__)
try:
#
# Python2 section
#
basestring = basestring
reduce = reduce
file = file
class PermissionError(Exception):
pass
except NameError:
#
# Python3 section
#
basestring = (str, bytes)
from functools import reduce
reduce = reduce
file = io.BytesIO
PermissionError = PermissionError
AF_MPLS = 28
AF_PIPE = 255 # Right now AF_MAX == 40
......
'''
AAA concept
-----------
AAA refers to Authentication, Authorization and Accounting. NDB provides
a minimalistic API to integrate Authorization routines, leaving the
rest -- Authentication and Accounting -- to the user.
Some of NDB routines and RTNL object methods are guarded with a
parametrized decorator. The decorator takes the only parameter `tag`::
@check_auth('obj:read')
def __getitem__(self, key):
...
@check_auth('obj:modify')
def __setitem__(self, key, value):
...
AuthManager
-----------
The tag is checked by `AuthManager.check(...)` routine. The routine is
the only method that must be provided by AuthManager-compatible objects,
and must be defined as::
def check(self, obj, tag):
# -> True: grant access to the tag
# -> False: reject access
# -> raise Exception(): reject access with a specific exception
...
NDB module provides an example AuthManager::
from pyroute2 import NDB
from pyroute2.ndb.auth_manager import AuthManager
ndb = NDB(log='debug')
am = AuthManager({'obj:list': False, # deny dump(), summary()
'obj:read': True, # permit reading RTNL attributes
'obj:modify': True}, # permit add_ip(), commit() etc.
ndb.log.channel('auth'))
ap = ndb.auth_proxy(am)
ap.interfaces.summary() # <-- fails with PermissionError
You can implement custom AuthManager classes, the only requirement -- they
must provide `.check(self, obj, tag)` routine, which returns `True` or
`False` or raises an exception.
Usecase: OpenStack Keystone auth
--------------------------------
Say we have a public service that provides access to NDB instance via
HTTP, and authenticates users via Keystone. Then the auth flow could be:
1. Accept a connection from a client
2. Create custom auth manager object A
3. A.__init__() validates X-Auth-Token against Keystone (Authentication)
4. A.check() checks that X-Auth-Token is not expired (Authorization)
5. The auth result is being logged (Accounting)
An example AuthManager with OpenStack APIv3 support you may find in the
`/examples/ndb/` directory.
'''
from pyroute2.common import PermissionError
class check_auth(object):
def __init__(self, tag):
self.tag = tag
def __call__(self, f):
def guard(obj, *argv, **kwarg):
if not getattr(obj, '_init_complete', True):
return f(obj, *argv, **kwarg)
if not obj.auth_managers:
raise PermissionError('access rejected')
if all([x.check(obj, self.tag) for x in obj.auth_managers]):
return f(obj, *argv, **kwarg)
raise PermissionError('access rejected')
return guard
class AuthManager(object):
def __init__(self, auth, log, policy=False):
self.auth = auth
self.log = log
self.policy = policy
self.exception = PermissionError
def check(self, obj, tag):
ret = self.policy
self.log.debug('%s %s auth=%s' % (id(obj), tag, self.auth))
if isinstance(self.auth, dict):
ret = self.auth.get(tag, self.policy)
if not ret and self.exception:
raise self.exception('%s access rejected' % (tag, ))
return ret
......@@ -150,6 +150,8 @@ from pyroute2.ndb.messages import (cmsg,
cmsg_failed,
cmsg_sstart)
from pyroute2.ndb.source import Source
from pyroute2.ndb.auth_manager import check_auth
from pyroute2.ndb.auth_manager import AuthManager
from pyroute2.ndb.objects.interface import Interface
from pyroute2.ndb.objects.interface import Vlan
from pyroute2.ndb.objects.address import Address
......@@ -157,7 +159,7 @@ from pyroute2.ndb.objects.route import Route
from pyroute2.ndb.objects.neighbour import Neighbour
from pyroute2.ndb.objects.rule import Rule
from pyroute2.ndb.objects.netns import NetNS
from pyroute2.ndb.query import Query
# from pyroute2.ndb.query import Query
from pyroute2.ndb.report import (RecordSet,
Record)
try:
......@@ -214,14 +216,20 @@ class View(dict):
ndb,
table,
chain=None,
default_target='localhost'):
default_target='localhost',
auth_managers=None):
self.ndb = ndb
self.log = ndb.log.channel('view.%s' % table)
self.table = table
self.event = table # FIXME
self.chain = chain
self.cache = {}
if auth_managers is None:
auth_managers = []
if chain:
auth_managers += chain.auth_managers
self.default_target = default_target
self.auth_managers = auth_managers
self.constraints = {}
self.classes = OrderedDict()
self.classes['interfaces'] = Interface
......@@ -362,6 +370,7 @@ class View(dict):
gc.collect()
return ret
@check_auth('obj:read')
def __getitem__(self, key, table=None):
if self.chain:
......@@ -372,7 +381,11 @@ class View(dict):
if isinstance(key, Record):
key = key._as_dict()
key = iclass.adjust_spec(key, context)
ret = iclass(self, key, load=False, master=self.chain)
ret = iclass(self,
key,
load=False,
master=self.chain,
auth_managers=self.auth_managers)
# rtnl_object.key() returns a dcitionary that can not
# be used as a cache key. Create here a tuple from it.
......@@ -426,14 +439,17 @@ class View(dict):
def __iter__(self):
return self.keys()
@check_auth('obj:list')
def keys(self):
for record in self.dump():
yield record
@check_auth('obj:list')
def values(self):
for key in self.keys():
yield self[key]
@check_auth('obj:list')
def items(self):
for key in self.keys():
yield (key, self[key])
......@@ -458,11 +474,13 @@ class View(dict):
yield Record(fnames, record)
@cli.show_result
@check_auth('obj:list')
def dump(self):
iclass = self.classes[self.table]
return RecordSet(self._native(iclass.dump(self)))
@cli.show_result
@check_auth('obj:list')
def summary(self):
iclass = self.classes[self.table]
return RecordSet(self._native(iclass.summary(self)))
......@@ -549,6 +567,9 @@ class Log(object):
if target in ('on', 'stderr'):
handler = logging.StreamHandler()
elif target == 'debug':
handler = logging.StreamHandler()
level = logging.DEBUG
elif isinstance(target, basestring):
url = urlparse(target)
if not url.scheme and url.path:
......@@ -645,6 +666,26 @@ def Events(*argv):
yield item
class AuthProxy(object):
def __init__(self, ndb, auth_managers):
self._ndb = ndb
self._auth_managers = auth_managers
for spec in (('interfaces', 'localhost'),
('addresses', 'localhost'),
('routes', 'localhost'),
('neighbours', 'localhost'),
('rules', 'localhost'),
('netns', 'nsmanager'),
('vlans', 'localhost')):
view = View(self._ndb,
spec[0],
default_target=spec[1],
auth_managers=self._auth_managers)
setattr(self, spec[0], view)
class NDB(object):
def __init__(self,
......@@ -709,14 +750,23 @@ class NDB(object):
for event in tuple(self._dbm_autoload):
event.wait()
self._dbm_autoload = None
self.interfaces = View(self, 'interfaces')
self.addresses = View(self, 'addresses')
self.routes = View(self, 'routes')
self.neighbours = View(self, 'neighbours')
self.rules = View(self, 'rules')
self.netns = View(self, 'netns', default_target='nsmanager')
self.vlans = View(self, 'vlans')
self.query = Query(self.schema)
am = AuthManager({'obj:list': True,
'obj:read': True,
'obj:modify': True},
self.log.channel('auth'))
for spec in (('interfaces', 'localhost'),
('addresses', 'localhost'),
('routes', 'localhost'),
('neighbours', 'localhost'),
('rules', 'localhost'),
('netns', 'nsmanager'),
('vlans', 'localhost')):
view = View(self,
spec[0],
default_target=spec[1],
auth_managers=[am])
setattr(self, spec[0], view)
# self.query = Query(self.schema)
def _get_view(self, name, chain=None):
return View(self, name, chain)
......@@ -727,6 +777,9 @@ class NDB(object):
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def auth_proxy(self, auth_manager):
return AuthProxy(self, [auth_manager, ])
def register_handler(self, event, handler):
if event not in self._event_map:
self._event_map[event] = []
......
......@@ -95,6 +95,8 @@ from functools import partial
from pyroute2 import cli
from pyroute2.ndb.events import State
from pyroute2.ndb.report import Record
from pyroute2.ndb.auth_manager import check_auth
from pyroute2.ndb.auth_manager import AuthManager
from pyroute2.netlink.exceptions import NetlinkError
from pyroute2.ndb.events import InvalidateHandlerException
......@@ -120,6 +122,7 @@ class RTNL_Object(dict):
_key = None
_replace = None
_replace_on_key_change = False
_init_complete = False
# 8<------------------------------------------------------------
#
......@@ -219,7 +222,8 @@ class RTNL_Object(dict):
iclass,
ctxid=None,
load=True,
master=None):
master=None,
auth_managers=None):
self.view = view
self.ndb = view.ndb
self.sources = view.ndb.sources
......@@ -233,6 +237,9 @@ class RTNL_Object(dict):
self.atime = time.time()
self.log = self.ndb.log.channel('rtnl_object')
self.log.debug('init')
if auth_managers is None:
auth_managers = [AuthManager(None, self.ndb.log.channel('auth'))]
self.auth_managers = auth_managers
self.state = State()
self.state.set('invalid')
self.snapshot_deps = []
......@@ -243,6 +250,8 @@ class RTNL_Object(dict):
self.knorm = self.schema.compiled[self.table]['norm_idx']
self.spec = self.schema.compiled[self.table]['all_names']
self.names = self.schema.compiled[self.table]['norm_names']
if self.event_map is None:
self.event_map = {}
self._apply_script = []
if isinstance(key, dict):
self.chain = key.pop('ndb_chain', None)
......@@ -268,6 +277,7 @@ class RTNL_Object(dict):
self.load_sql()
else:
self.load_sql(table=self.table)
self._init_complete = True
def mark_tflags(self, mark):
pass
......@@ -309,11 +319,18 @@ class RTNL_Object(dict):
def __hash__(self):
return id(self)
@check_auth('obj:read')
def __getitem__(self, key):
return dict.__getitem__(self, key)
@check_auth('obj:modify')
def __setitem__(self, key, value):
if self.state == 'system' and key in self.knorm:
if self._replace_on_key_change:
self.log.debug('prepare replace for key %s' % (self.key))
self._replace = type(self)(self.view, self.key)
self._replace = type(self)(self.view,
self.key,
auth_managers=self.auth_managers)
self.state.set('replace')
else:
raise ValueError('attempt to change a key field (%s)' % key)
......@@ -338,6 +355,7 @@ class RTNL_Object(dict):
return self.view[spec]
@cli.show_result
@check_auth('obj:read')
def show(self, **kwarg):
'''
Return the object in a specified format. The format may be
......@@ -405,6 +423,7 @@ class RTNL_Object(dict):
.register_handler(event,
partial(wr_handler, wr, fname)))
@check_auth('obj:modify')
def snapshot(self, ctxid=None):
'''
Create and return a snapshot of the object. The method creates
......@@ -419,7 +438,10 @@ class RTNL_Object(dict):
key = self.key
else:
key = self._replace.key
snp = type(self)(self.view, key, ctxid=ctxid)
snp = type(self)(self.view,
key,
ctxid=ctxid,
auth_managers=self.auth_managers)
self.ndb.schema.save_deps(ctxid, weakref.ref(snp), self.iclass)
snp.changed = set(self.changed)
return snp
......@@ -472,6 +494,7 @@ class RTNL_Object(dict):
self.log.debug('got %s' % key)
return key
@check_auth('obj:modify')
def rollback(self, snapshot=None):
'''
Try to rollback the object state using the snapshot provided as
......@@ -480,7 +503,9 @@ class RTNL_Object(dict):
if self._replace is not None:
self.log.debug('rollback replace: %s :: %s'
% (self.key, self._replace.key))
new_replace = type(self)(self.view, self.key)
new_replace = type(self)(self.view,
self.key,
auth_managers=self.auth_managers)
new_replace.state.set('remove')
self.state.set('replace')
self.update(self._replace)
......@@ -502,6 +527,7 @@ class RTNL_Object(dict):
not self.changed and \
not self._apply_script
@check_auth('obj:modify')
def commit(self):
'''
Try to commit the pending changes. If the commit fails,
......@@ -616,6 +642,7 @@ class RTNL_Object(dict):
def hook_apply(self, method, **spec):
pass
@check_auth('obj:modify')
def apply(self, rollback=False):
'''
Create a snapshot and apply pending changes. Do not revert
......
......@@ -98,6 +98,8 @@ from pyroute2.common import basestring
from pyroute2.netlink.rtnl.ifinfmsg import ifinfmsg
from pyroute2.netlink.rtnl.p2pmsg import p2pmsg
from pyroute2.netlink.exceptions import NetlinkError
from pyroute2.ndb.auth_manager import AuthManager
from pyroute2.ndb.auth_manager import check_auth
def load_ifinfmsg(schema, target, event):
......@@ -293,7 +295,10 @@ class Vlan(RTNL_Object):
def __init__(self, *argv, **kwarg):
kwarg['iclass'] = ifinfmsg.af_spec_bridge.vlan_info
self.event_map = {ifinfmsg: "load_rtnlmsg"}
if 'auth_managers' not in kwarg or kwarg['auth_managers'] is None:
kwarg['auth_managers'] = []
log = argv[0].ndb.log.channel('vlan auth')
kwarg['auth_managers'].append(AuthManager({'obj:modify': False}, log))
super(Vlan, self).__init__(*argv, **kwarg)
def make_req(self, prime):
......@@ -405,6 +410,7 @@ class Interface(RTNL_Object):
ret.update(context)
return ret
@check_auth('obj:modify')
def add_vlan(self, spec):
def do_add_vlan(self, spec):
try:
......@@ -415,6 +421,7 @@ class Interface(RTNL_Object):
self._apply_script.append((do_add_vlan, (self, spec), {}))
return self
@check_auth('obj:modify')
def del_vlan(self, spec):
def do_del_vlan(self, spec):
try:
......@@ -426,6 +433,7 @@ class Interface(RTNL_Object):
self._apply_script.append((do_del_vlan, (self, spec), {}))
return self
@check_auth('obj:modify')
def add_ip(self, spec):
def do_add_ip(self, spec):
try:
......@@ -436,6 +444,7 @@ class Interface(RTNL_Object):
self._apply_script.append((do_add_ip, (self, spec), {}))
return self
@check_auth('obj:modify')
def del_ip(self, spec):
def do_del_ip(self, spec):
try:
......@@ -447,6 +456,7 @@ class Interface(RTNL_Object):
self._apply_script.append((do_del_ip, (self, spec), {}))
return self
@check_auth('obj:modify')
def add_port(self, spec):
def do_add_port(self, spec):
try:
......@@ -461,6 +471,7 @@ class Interface(RTNL_Object):
self._apply_script.append((do_add_port, (self, spec), {}))
return self
@check_auth('obj:modify')
def del_port(self, spec):
def do_del_port(self, spec):
try:
......@@ -476,6 +487,7 @@ class Interface(RTNL_Object):
self._apply_script.append((do_del_port, (self, spec), {}))
return self
@check_auth('obj:modify')
def __setitem__(self, key, value):
if key == 'peer':
dict.__setitem__(self, key, value)
......@@ -512,7 +524,9 @@ class Interface(RTNL_Object):
.interfaces
.getmany({'IFLA_MASTER': self['index']})):
# bridge ports
link = type(self)(self.view, spec)
link = type(self)(self.view,
spec,
auth_managers=self.auth_managers)
snp.snapshot_deps.append((link, link.snapshot()))
for spec in (self
.ndb
......@@ -520,7 +534,9 @@ class Interface(RTNL_Object):
.getmany({'IFLA_LINK': self['index']})):
# vlans & veth
if self.get('link') != spec['index']:
link = type(self)(self.view, spec)
link = type(self)(self.view,
spec,
auth_managers=self.auth_managers)
snp.snapshot_deps.append((link, link.snapshot()))
# return the root node
return snp
......@@ -531,6 +547,7 @@ class Interface(RTNL_Object):
req['master'] = self['master']
return req
@check_auth('obj:modify')
def apply(self, rollback=False, fallback=False):
# translate string link references into numbers
for key in ('link', ):
......@@ -546,7 +563,9 @@ class Interface(RTNL_Object):
key = dict(self)
key['create'] = True
del key['master']
fb = type(self)(self.view, key)
fb = type(self)(self.view,
key,
auth_managers=self.auth_managers)
fb.register()
fb.apply(rollback)
fb.set('master', self['master'])
......
......@@ -84,6 +84,7 @@ from functools import partial
from collections import OrderedDict
from pyroute2.ndb.objects import RTNL_Object
from pyroute2.ndb.report import Record
from pyroute2.ndb.auth_manager import check_auth
from pyroute2.common import basestring
from pyroute2.common import AF_MPLS
from pyroute2.netlink.rtnl.rtmsg import rtmsg
......@@ -539,6 +540,7 @@ class Route(RTNL_Object):
req['gateway'] = self['gateway']
return req
@check_auth('obj:modify')
def __setitem__(self, key, value):
if key in ('dst', 'src') and \
isinstance(value, basestring) and \
......@@ -559,7 +561,10 @@ class Route(RTNL_Object):
mp = dict(mp)
if self.state == 'invalid':
mp['create'] = True
obj = NextHop(self, self.view, mp)
obj = NextHop(self,
self.view