blob: 005bed37e859c9ac3f080c4ac4a4bfe8fe4c8b5d [file] [log] [blame]
# Copyright 2017-present Open Networking Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import print_function
from authhelper import XOSAuthHelperMixin
import datetime
import inspect
import pytz
from protos import xos_pb2
from google.protobuf.empty_pb2 import Empty
from django.db.models import F, Q
from core.models import Site, User, XOSBase
from xos.exceptions import (
XOSPermissionDenied,
XOSNotFound,
)
from xosconfig import Config
from multistructlog import create_logger
log = create_logger(Config().get("logging"))
class XOSDefaultSecurityContext(object):
grant_access = True
write_access = True
read_access = True
xos_anonymous_site = Site(
name="XOS Anonymous Site",
enabled=True,
hosts_nodes=False,
hosts_users=True,
login_base="xos",
abbreviated_name="xos-anonymous",
)
xos_anonymous_user = User(
username="XOS Anonymous User",
email="xos@example.com",
is_admin=False,
site=xos_anonymous_site,
)
class XOSAPIHelperMixin(XOSAuthHelperMixin):
""" This helper contains several functions used to implement the autogenerated core API.
It translates between the gRPC representation of objects and the django representation
of objects. It implements functions that handle the heavy lifting of the
create/read/update/delete functions.
Inherits authentication functionality from AuthHelperMixin.
"""
def __init__(self):
import django.apps
self.models = {}
for model in django.apps.apps.get_models():
self.models[model.__name__] = model
def get_model(self, name):
return self.models[name]
def getProtoClass(self, djangoClass):
pClass = getattr(xos_pb2, djangoClass.__name__)
return pClass
def getPluralProtoClass(self, djangoClass):
pClass = getattr(xos_pb2, djangoClass.plural_name)
return pClass
def convertFloat(self, x):
if not x:
return 0
else:
return float(x)
def convertDateTime(self, x):
if not x:
return 0
else:
utc = pytz.utc
return (x - datetime.datetime(1970, 1, 1, tzinfo=utc)).total_seconds()
# return time.mktime(x.timetuple())
def convertForeignKey(self, x):
if not x:
return 0
else:
return int(x.id)
def objToProto(self, obj):
p_obj = self.getProtoClass(obj.__class__)()
for field in obj._meta.fields:
if getattr(obj, field.name) is None:
continue
ftype = field.get_internal_type()
if (
(ftype == "CharField")
or (ftype == "TextField")
or (ftype == "SlugField")
):
setattr(p_obj, field.name, str(getattr(obj, field.name)))
elif ftype == "BooleanField":
setattr(p_obj, field.name, getattr(obj, field.name))
elif ftype == "AutoField":
setattr(p_obj, field.name, int(getattr(obj, field.name)))
elif (
(ftype == "IntegerField")
or (ftype == "PositiveIntegerField")
or (ftype == "BigIntegerField")
):
setattr(p_obj, field.name, int(getattr(obj, field.name)))
elif ftype == "ForeignKey":
setattr(
p_obj,
field.name + "_id",
self.convertForeignKey(getattr(obj, field.name)),
)
elif ftype == "DateTimeField":
setattr(
p_obj, field.name, self.convertDateTime(getattr(obj, field.name))
)
elif ftype == "FloatField":
setattr(p_obj, field.name, float(getattr(obj, field.name)))
elif ftype == "GenericIPAddressField":
setattr(p_obj, field.name, str(getattr(obj, field.name)))
# Introspecting the django object for related objects is problematic due to _decl-style attics. The descendant
# class's _meta's related_objects doesn't include related objects from the base. For example, VSGServiceInstance
# was missing provided_links and subscribed_links, since those were declared in ServiceInstance. (This problem
# does not exist with older style attics)
#
# Instead, look through the protobuf object since we know it's right because we generated it from xproto. Look
# for any field that ended in "_ids", and use that to extract the appropriate field from the django
# object. This handles both ManyToOne reverse relations and ManyToMany.
for field_name in p_obj.DESCRIPTOR.fields_by_name.keys():
if not field_name.endswith("_ids"):
# only look for reverse relations
continue
related_name = field_name[:-4]
if not hasattr(obj, related_name):
# if field doesn't exist in the django object, then ignore it
log.warning(
"Protobuf field %s doesn't have a corresponding django field"
% field_name
)
continue
try:
rel_objs = getattr(obj, related_name)
except Exception as e:
# django makes catching this exception unnecessarily difficult
if type(e).__name__ == "RelatedObjectDoesNotExist":
# OneToOneField throws this if relation does not exist
continue
else:
raise
if not hasattr(rel_objs, "all"):
# this is in anticipation of OneToOneField causing problems
continue
for rel_obj in rel_objs.all():
if not hasattr(p_obj, field_name):
continue
getattr(p_obj, field_name).append(rel_obj.id)
# Generate a list of class names for the object. This includes its
# ancestors. Anything that is a descendant of XOSBase or User
# counts.
bases = inspect.getmro(obj.__class__)
bases = [x for x in bases if issubclass(x, XOSBase) or issubclass(x, User)]
p_obj.class_names = ",".join([x.__name__ for x in bases])
p_obj.self_content_type_id = obj.get_content_type_key()
return p_obj
def protoToArgs(self, djangoClass, message):
args = {}
fmap = {}
fset = {}
for field in djangoClass._meta.fields:
fmap[field.name] = field
if field.get_internal_type() == "ForeignKey":
# foreign key can be represented as an id
fmap[field.name + "_id"] = field
for (fieldDesc, val) in message.ListFields():
name = fieldDesc.name
if name in fmap:
if name == "id":
# don't let anyone set the id
continue
ftype = fmap[name].get_internal_type()
if (
(ftype == "CharField")
or (ftype == "TextField")
or (ftype == "SlugField")
):
args[name] = val
elif ftype == "BooleanField":
args[name] = val
elif ftype == "AutoField":
args[name] = val
elif (
(ftype == "IntegerField")
or (ftype == "PositiveIntegerField")
or (ftype == "BigIntegerField")
):
args[name] = val
elif ftype == "ForeignKey":
if val == 0: # assume object id 0 means None
args[name] = None
else:
# field name already has "_id" at the end
args[name] = val
elif ftype == "DateTimeField":
utc = pytz.utc
args[name] = datetime.datetime.fromtimestamp(val, tz=utc)
elif ftype == "FloatField":
args[name] = val
elif ftype == "GenericIPAddressField":
args[name] = val
fset[name] = True
return args
def handle_m2m(self, djangoClass, message, update_fields):
# fix for possible django bug?
# Unless we refresh the object, django will ignore every other m2m save
# djangoClass = djangoClass.__class__.objects.get(id=djangoClass.id)
djangoClass.refresh_from_db()
fmap = {}
for m2m in djangoClass._meta.many_to_many:
related_name = m2m.name
if not related_name:
continue
if (
"+" in related_name
): # duplicated logic from related_objects; not sure if necessary
continue
fmap[m2m.name + "_ids"] = m2m
fields_changed = []
for (fieldDesc, val) in message.ListFields():
if fieldDesc.name in fmap:
m2m = getattr(djangoClass, fmap[fieldDesc.name].name)
# remove items that are in the django object, but not in the proto object
for item in list(m2m.all()):
if item.id not in val:
m2m.remove(item.id)
fields_changed.append(fieldDesc.name)
# add items are are in the proto object, but not in the django object
django_ids = [x.id for x in m2m.all()]
for item in val:
if item not in django_ids:
m2m.add(item)
fields_changed.append(fieldDesc.name)
# gRPC doesn't give us a convenient way to differentiate between an empty list and an omitted list. So what
# we'll do is check and see if the user specified a fieldname in `update_fields`. If the user did, and that
# field is an m2m that we didn't encounter, then it must have been an empty list that the user wants
# to set.
for name in update_fields:
if (name in fmap) and (name not in fields_changed):
m2m = getattr(djangoClass, fmap[name].name)
m2m.clear()
fields_changed.append(name)
if fields_changed:
djangoClass.save()
def querysetToProto(self, djangoClass, queryset):
objs = queryset
p_objs = self.getPluralProtoClass(djangoClass)()
for obj in objs:
new_obj = p_objs.items.add()
new_obj.CopyFrom(self.objToProto(obj))
return p_objs
def get_live_or_deleted_object(self, djangoClass, id):
""" Given an id, retrieve the object regardless of whether the object is live or deleted. """
try:
obj = None
# First, check to see if the object has been deleted. Maybe the caller is
# trying to update the policed timestamp of a deleted object.
if hasattr(djangoClass, "deleted_objects"):
deleted_objects = djangoClass.deleted_objects.filter(id=id)
if deleted_objects:
obj = deleted_objects[0]
# No deleted object was found, so check for a live object.
if not obj:
obj = djangoClass.objects.get(id=id)
return obj
except djangoClass.DoesNotExist as e:
raise XOSNotFound(fields={"id": id, "message": e.message})
def xos_security_gate(self, obj, user, **access_types):
sec_ctx = XOSDefaultSecurityContext()
if not user:
user = xos_anonymous_user
sec_ctx.user = user
for k, v in access_types.items():
setattr(sec_ctx, k, v)
verdict, policy_name = obj.can_access(ctx=sec_ctx)
# FIXME: This is the central point of enforcement for security policies
# Implement Auditing here.
# logging.info( ... )
if not verdict:
# logging.critical( ... )
if obj.id:
object_descriptor = "object %d" % obj.id
else:
object_descriptor = "new object"
raise XOSPermissionDenied(
"User %(user_email)s cannot access %(django_class_name)s %(descriptor)s due to policy %(policy_name)s"
% {
"user_email": user.email,
"django_class_name": obj.__class__.__name__,
"policy_name": policy_name,
"descriptor": object_descriptor,
}
)
def xos_security_check(self, obj, user, **access_types):
sec_ctx = XOSDefaultSecurityContext()
if not user:
user = xos_anonymous_user
sec_ctx.user = user
for k, v in access_types.items():
setattr(sec_ctx, k, v)
verdict, _ = obj.can_access(ctx=sec_ctx)
return verdict
def get(self, djangoClass, user, id):
obj = self.get_live_or_deleted_object(djangoClass, id)
self.xos_security_gate(obj, user, read_access=True)
return self.objToProto(obj)
def create(self, djangoClass, user, request):
try:
args = self.protoToArgs(djangoClass, request)
new_obj = djangoClass(**args)
new_obj.caller = user
self.xos_security_gate(new_obj, user, write_access=True)
new_obj.save()
self.handle_m2m(new_obj, request, [])
response = self.objToProto(new_obj)
return response
except BaseException:
log.exception("Exception in apihelper.create")
raise
def update(self, djangoClass, user, id, message, context):
try:
obj = self.get_live_or_deleted_object(djangoClass, id)
obj.caller = user
self.xos_security_gate(obj, user, write_access=True)
args = self.protoToArgs(djangoClass, message)
for (k, v) in args.iteritems():
setattr(obj, k, v)
m2m_field_names = [x.name + "_ids" for x in djangoClass._meta.many_to_many]
update_fields = []
m2m_update_fields = []
save_kwargs = {}
for (k, v) in context.invocation_metadata():
if k == "update_fields":
for field_name in v.split(","):
if field_name in m2m_field_names:
m2m_update_fields.append(field_name)
else:
update_fields.append(field_name)
save_kwargs["update_fields"] = update_fields
elif k == "caller_kind":
save_kwargs["caller_kind"] = v
elif k == "always_update_timestamp":
save_kwargs["always_update_timestamp"] = True
elif k == "is_sync_save":
save_kwargs["is_sync_save"] = True
elif k == "is_policy_save":
save_kwargs["is_policy_save"] = True
obj.save(**save_kwargs)
# CORD-3088: Do not call handle_m2m for deleted objects
if not obj.deleted:
self.handle_m2m(obj, message, m2m_update_fields)
response = self.objToProto(obj)
return response
except BaseException:
log.exception("Exception in apihelper.update")
raise
def delete(self, djangoClass, user, id):
try:
obj = djangoClass.objects.get(id=id)
self.xos_security_gate(obj, user, write_access=True)
obj.delete()
return Empty()
except BaseException:
log.exception("Exception in apihelper.delete")
raise
def query_element_to_q(self, element):
value = element.sValue
if element.HasField("iValue"):
value = element.iValue
elif element.HasField("sValue"):
value = element.sValue
else:
raise Exception("must specify iValue or sValue")
if element.operator == element.EQUAL:
q = Q(**{element.name: value})
elif element.operator == element.LESS_THAN:
q = Q(**{element.name + "__lt": value})
elif element.operator == element.LESS_THAN_OR_EQUAL:
q = Q(**{element.name + "__lte": value})
elif element.operator == element.GREATER_THAN:
q = Q(**{element.name + "__gt": value})
elif element.operator == element.GREATER_THAN_OR_EQUAL:
q = Q(**{element.name + "__gte": value})
elif element.operator == element.IEXACT:
q = Q(**{element.name + "__iexact": value})
else:
raise Exception("unknown operator")
if element.invert:
q = ~q
return q
def list(self, djangoClass, user):
try:
queryset = djangoClass.objects.all()
filtered_queryset = (
elt
for elt in queryset
if self.xos_security_check(elt, user, read_access=True)
)
# FIXME: Implement auditing here
# logging.info("User requested x objects, y objects were filtered out by policy z")
response = self.querysetToProto(djangoClass, filtered_queryset)
return response
except BaseException:
log.exception("Exception in apihelper.list")
raise
def build_filter(self, request, query=None):
""" Given a filter request, turn it into a django query.
If argument query is not None, then the new query will be appended to the existing query.
"""
for element in request.elements:
if query:
query = query & self.query_element_to_q(element)
else:
query = self.query_element_to_q(element)
return query
def filter(self, djangoClass, user, request):
try:
if request.kind == request.DEFAULT:
query = self.build_filter(request, None)
queryset = djangoClass.objects.filter(query)
elif request.kind == request.SYNCHRONIZER_DIRTY_OBJECTS:
query = (
(
Q(enacted=None)
| Q(enacted__lt=F("updated"))
| Q(enacted__lt=F("changed_by_policy"))
)
& Q(lazy_blocked=False)
& Q(no_sync=False)
)
query = self.build_filter(request, query)
queryset = djangoClass.objects.filter(query)
elif request.kind == request.SYNCHRONIZER_DELETED_OBJECTS:
query = self.build_filter(request, None)
if query:
queryset = djangoClass.deleted_objects.filter(query)
else:
queryset = djangoClass.deleted_objects.all()
elif request.kind == request.SYNCHRONIZER_DIRTY_POLICIES:
query = (
Q(policed=None)
| Q(policed__lt=F("updated"))
| Q(policed__lt=F("changed_by_step"))
) & Q(no_policy=False)
query = self.build_filter(request, query)
queryset = djangoClass.objects.filter(query)
elif request.kind == request.SYNCHRONIZER_DELETED_POLICIES:
query = Q(policed__lt=F("updated")) | Q(policed=None)
query = self.build_filter(request, query)
queryset = djangoClass.deleted_objects.filter(query)
elif request.kind == request.ALL:
queryset = djangoClass.objects.all()
filtered_queryset = (
elt
for elt in queryset
if self.xos_security_check(elt, user, read_access=True)
)
# FIXME: Implement auditing here
# logging.info("User requested x objects, y objects were filtered out by policy z")
response = self.querysetToProto(djangoClass, filtered_queryset)
return response
except BaseException:
log.exception("Exception in apihelper.filter")
raise