From c6c5ca042f00f0d45d000eaea3de475ff177cd0b Mon Sep 17 00:00:00 2001 From: zhuli Date: Fri, 25 Aug 2017 18:30:15 +0800 Subject: [PATCH] add policy support Add policy support to determine which user can access which objects in which way Change-Id: If959089366ec252d4a7904d0e78733a2bf52fff5 --- .gitignore | 3 + cyborg/api/controllers/v1/accelerators.py | 12 +- cyborg/api/hooks.py | 47 +++- cyborg/common/exception.py | 9 + cyborg/common/policy.py | 234 ++++++++++++++++++ .../f50980397351_initial_migration.py | 2 + cyborg/db/sqlalchemy/models.py | 2 + cyborg/objects/accelerator.py | 2 + etc/cyborg/README.policy.json.txt | 4 + etc/cyborg/policy.json | 4 + requirements.txt | 1 + setup.cfg | 3 + tools/config/cyborg-policy-generator.conf | 3 + tox.ini | 6 + 14 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 cyborg/common/policy.py create mode 100644 etc/cyborg/README.policy.json.txt create mode 100644 etc/cyborg/policy.json create mode 100644 tools/config/cyborg-policy-generator.conf diff --git a/.gitignore b/.gitignore index 59595b91..9404bb98 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ *.tox *.testrepository cyborg.egg-info + +# Sample profile +etc/cyborg/policy.json.sample diff --git a/cyborg/api/controllers/v1/accelerators.py b/cyborg/api/controllers/v1/accelerators.py index 5059455c..ca254765 100644 --- a/cyborg/api/controllers/v1/accelerators.py +++ b/cyborg/api/controllers/v1/accelerators.py @@ -23,6 +23,7 @@ from cyborg.api.controllers import base from cyborg.api.controllers import link from cyborg.api.controllers.v1 import types from cyborg.api import expose +from cyborg.common import policy from cyborg import objects @@ -37,6 +38,8 @@ class Accelerator(base.APIBase): uuid = types.uuid name = wtypes.text description = wtypes.text + project_id = types.uuid + user_id = types.uuid device_type = wtypes.text acc_type = wtypes.text acc_capability = wtypes.text @@ -67,9 +70,16 @@ class Accelerator(base.APIBase): return accelerator -class AcceleratorsController(rest.RestController): +class AcceleratorsControllerBase(rest.RestController): + def _get_resource(self, uuid): + self._resource = objects.Accelerator.get(pecan.request.context, uuid) + return self._resource + + +class AcceleratorsController(AcceleratorsControllerBase): """REST controller for Accelerators.""" + @policy.authorize_wsgi("cyborg:accelerator", "create", False) @expose.expose(Accelerator, body=types.jsontype, status_code=http_client.CREATED) def post(self, values): diff --git a/cyborg/api/hooks.py b/cyborg/api/hooks.py index dfc19188..ffc3744e 100644 --- a/cyborg/api/hooks.py +++ b/cyborg/api/hooks.py @@ -17,6 +17,7 @@ from oslo_config import cfg from oslo_context import context from pecan import hooks +from cyborg.common import policy from cyborg.conductor import rpcapi @@ -50,11 +51,53 @@ class ConductorAPIHook(hooks.PecanHook): class ContextHook(hooks.PecanHook): - """Configures a request context and attaches it to the request.""" + """Configures a request context and attaches it to the request. + + The following HTTP request headers are used: + + X-User-Id or X-User: + Used for context.user. + + X-Tenant-Id or X-Tenant: + Used for context.tenant. + + X-Auth-Token: + Used for context.auth_token. + + X-Roles: + Used for setting context.is_admin flag to either True or False. + The flag is set to True, if X-Roles contains either an administrator + or admin substring. Otherwise it is set to False. + + """ def __init__(self, public_api_routes): self.public_api_routes = public_api_routes super(ContextHook, self).__init__() def before(self, state): - state.request.context = context.get_admin_context() + headers = state.request.headers + + creds = { + 'user_name': headers.get('X-User-Name'), + 'user': headers.get('X-User-Id'), + 'project_name': headers.get('X-Project-Name'), + 'tenant': headers.get('X-Project-Id'), + 'domain': headers.get('X-User-Domain-Id'), + 'domain_name': headers.get('X-User-Domain-Name'), + 'auth_token': headers.get('X-Auth-Token'), + 'roles': headers.get('X-Roles', '').split(','), + } + + is_admin = policy.authorize('is_admin', creds, creds) + state.request.context = context.RequestContext( + is_admin=is_admin, **creds) + + def after(self, state): + if state.request.context == {}: + # An incorrect url path will not create RequestContext + return + # RequestContext will generate a request_id if no one + # passing outside, so it always contain a request_id. + request_id = state.request.context.request_id + state.response.headers['Openstack-Request-Id'] = request_id diff --git a/cyborg/common/exception.py b/cyborg/common/exception.py index e3dc7f21..6959d5c0 100644 --- a/cyborg/common/exception.py +++ b/cyborg/common/exception.py @@ -109,3 +109,12 @@ class InvalidUUID(Invalid): class InvalidJsonType(Invalid): _msg_fmt = _("%(value)s is not JSON serializable.") + + +class NotAuthorized(CyborgException): + _msg_fmt = _("Not authorized.") + code = http_client.FORBIDDEN + + +class HTTPForbidden(NotAuthorized): + _msg_fmt = _("Access was denied to the following resource: %(resource)s") diff --git a/cyborg/common/policy.py b/cyborg/common/policy.py new file mode 100644 index 00000000..bd6d71e9 --- /dev/null +++ b/cyborg/common/policy.py @@ -0,0 +1,234 @@ +# Copyright 2017 Huawei Technologies Co.,LTD. +# All Rights Reserved. +# +# 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. + +"""Policy Engine For Cyborg.""" + +import functools +import sys + +from oslo_concurrency import lockutils +from oslo_config import cfg +from oslo_log import log +from oslo_policy import policy +from oslo_versionedobjects import base as object_base +import pecan +import wsme + +from cyborg.common import exception + + +_ENFORCER = None +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +default_policies = [ + # Legacy setting, don't remove. Likely to be overridden by operators who + # forget to update their policy.json configuration file. + # This gets rolled into the new "is_admin" rule below. + policy.RuleDefault('admin_api', + 'role:admin or role:administrator', + description='Legacy rule for cloud admin access'), + # is_public_api is set in the environment from AuthTokenMiddleware + policy.RuleDefault('public_api', + 'is_public_api:True', + description='Internal flag for public API routes'), + # The policy check "@" will always accept an access. The empty list + # (``[]``) or the empty string (``""``) is equivalent to the "@" + policy.RuleDefault('allow', + '@', + description='any access will be passed'), + # the policy check "!" will always reject an access. + policy.RuleDefault('deny', + '!', + description='all access will be forbidden'), + policy.RuleDefault('is_admin', + 'rule:admin_api', + description='Full read/write API access'), + policy.RuleDefault('admin_or_owner', + 'is_admin:True or project_id:%(project_id)s', + description='Admin or owner API access'), + policy.RuleDefault('admin_or_user', + 'is_admin:True or user_id:%(user_id)s', + description='Admin or user API access'), + policy.RuleDefault('default', + 'rule:admin_or_owner', + description='Default API access rule'), +] + +# NOTE: to follow policy-in-code spec, we define defaults for +# the granular policies in code, rather than in policy.json. +# All of these may be overridden by configuration, but we can +# depend on their existence throughout the code. + +accelerator_policies = [ + policy.RuleDefault('cyborg:accelerator:get', + 'rule:default', + description='Retrieve accelerator records'), + policy.RuleDefault('cyborg:accelerator:create', + 'rule:allow', + description='Create accelerator records'), + policy.RuleDefault('cyborg:accelerator:delete', + 'rule:default', + description='Delete accelerator records'), + policy.RuleDefault('cyborg:accelerator:update', + 'rule:default', + description='Update accelerator records'), +] + + +def list_policies(): + return default_policies + accelerator_policies + + +@lockutils.synchronized('policy_enforcer', 'cyborg-') +def init_enforcer(policy_file=None, rules=None, + default_rule=None, use_conf=True): + """Synchronously initializes the policy enforcer + + :param policy_file: Custom policy file to use, if none is specified, + `CONF.oslo_policy.policy_file` will be used. + :param rules: Default dictionary / Rules to use. It will be + considered just in the first instantiation. + :param default_rule: Default rule to use, + CONF.oslo_policy.policy_default_rule will + be used if none is specified. + :param use_conf: Whether to load rules from config file. + + """ + global _ENFORCER + + if _ENFORCER: + return + + # NOTE: Register defaults for policy-in-code here so that they are + # loaded exactly once - when this module-global is initialized. + # Defining these in the relevant API modules won't work + # because API classes lack singletons and don't use globals. + _ENFORCER = policy.Enforcer(CONF, policy_file=policy_file, + rules=rules, + default_rule=default_rule, + use_conf=use_conf) + _ENFORCER.register_defaults(list_policies()) + + +def get_enforcer(): + """Provides access to the single accelerator of policy enforcer.""" + global _ENFORCER + + if not _ENFORCER: + init_enforcer() + + return _ENFORCER + + +# NOTE: We can't call these methods from within decorators because the +# 'target' and 'creds' parameter must be fetched from the call time +# context-local pecan.request magic variable, but decorators are compiled +# at module-load time. + + +def authorize(rule, target, creds, do_raise=False, *args, **kwargs): + """A shortcut for policy.Enforcer.authorize() + + Checks authorization of a rule against the target and credentials, and + raises an exception if the rule is not defined. + """ + enforcer = get_enforcer() + try: + return enforcer.authorize(rule, target, creds, do_raise=do_raise, + *args, **kwargs) + except policy.PolicyNotAuthorized: + raise exception.HTTPForbidden(resource=rule) + + +# This decorator MUST appear first (the outermost decorator) +# on an API method for it to work correctly +def authorize_wsgi(api_name, act=None, need_target=True): + """This is a decorator to simplify wsgi action policy rule check. + + :param api_name: The collection name to be evaluate. + :param act: The function name of wsgi action. + :param need_target: Whether need target for authorization. Such as, + when create some resource , maybe target is not needed. + + example: + from cyborg.common import policy + class AcceleratorController(rest.RestController): + .... + @policy.authorize_wsgi("cyborg:accelerator", "create", False) + @wsme_pecan.wsexpose(Accelerator, body=types.jsontype, + status_code=http_client.CREATED) + def post(self, values): + ... + """ + def wraper(fn): + action = '%s:%s' % (api_name, act or fn.__name__) + + # In this authorize method, we return a dict data when authorization + # fails or exception comes out. Maybe we can consider to use + # wsme.api.Response in future. + def return_error(resp_status): + exception_info = sys.exc_info() + orig_exception = exception_info[1] + orig_code = getattr(orig_exception, 'code', None) + pecan.response.status = orig_code or resp_status + data = wsme.api.format_exception( + exception_info, + pecan.conf.get('wsme', {}).get('debug', False) + ) + del exception_info + return data + + @functools.wraps(fn) + def handle(self, *args, **kwargs): + context = pecan.request.context + credentials = context.to_policy_values() + credentials['is_admin'] = context.is_admin + target = {} + # maybe we can pass "_get_resource" to authorize_wsgi + if need_target and hasattr(self, "_get_resource"): + try: + resource = getattr(self, "_get_resource")(*args, **kwargs) + # just support object, other type will just keep target as + # empty, then follow authorize method will fail and throw + # an exception + if isinstance(resource, + object_base.VersionedObjectDictCompat): + target = {'project_id': resource.project_id, + 'user_id': resource.user_id} + except Exception: + return return_error(500) + elif need_target: + # if developer do not set _get_resource, just set target as + # empty, then follow authorize method will fail and throw an + # exception + target = {} + else: + # for create method, before resource exsites, we can check the + # the credentials with itself. + target = {'project_id': context.tenant, + 'user_id': context.user} + + try: + authorize(action, target, credentials, do_raise=True) + except Exception: + return return_error(403) + + return fn(self, *args, **kwargs) + + return handle + + return wraper diff --git a/cyborg/db/sqlalchemy/alembic/versions/f50980397351_initial_migration.py b/cyborg/db/sqlalchemy/alembic/versions/f50980397351_initial_migration.py index 2b2a2b63..217e6e7a 100644 --- a/cyborg/db/sqlalchemy/alembic/versions/f50980397351_initial_migration.py +++ b/cyborg/db/sqlalchemy/alembic/versions/f50980397351_initial_migration.py @@ -37,6 +37,8 @@ def upgrade(): sa.Column('uuid', sa.String(length=36), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('description', sa.Text(), nullable=True), + sa.Column('project_id', sa.String(length=36), nullable=True), + sa.Column('user_id', sa.String(length=36), nullable=True), sa.Column('device_type', sa.Text(), nullable=False), sa.Column('acc_type', sa.Text(), nullable=False), sa.Column('acc_capability', sa.Text(), nullable=False), diff --git a/cyborg/db/sqlalchemy/models.py b/cyborg/db/sqlalchemy/models.py index a8fd9961..785b99df 100644 --- a/cyborg/db/sqlalchemy/models.py +++ b/cyborg/db/sqlalchemy/models.py @@ -64,6 +64,8 @@ class Accelerator(Base): uuid = Column(String(36), nullable=False) name = Column(String(255), nullable=False) description = Column(String(255), nullable=True) + project_id = Column(String(36), nullable=True) + user_id = Column(String(36), nullable=True) device_type = Column(String(255), nullable=False) acc_type = Column(String(255), nullable=False) acc_capability = Column(String(255), nullable=False) diff --git a/cyborg/objects/accelerator.py b/cyborg/objects/accelerator.py index 1859bbef..4295c91a 100644 --- a/cyborg/objects/accelerator.py +++ b/cyborg/objects/accelerator.py @@ -31,6 +31,8 @@ class Accelerator(base.CyborgObject, object_base.VersionedObjectDictCompat): 'uuid': object_fields.UUIDField(nullable=False), 'name': object_fields.StringField(nullable=False), 'description': object_fields.StringField(nullable=True), + 'project_id': object_fields.UUIDField(nullable=True), + 'user_id': object_fields.UUIDField(nullable=True), 'device_type': object_fields.StringField(nullable=False), 'acc_type': object_fields.StringField(nullable=False), 'acc_capability': object_fields.StringField(nullable=False), diff --git a/etc/cyborg/README.policy.json.txt b/etc/cyborg/README.policy.json.txt new file mode 100644 index 00000000..70285457 --- /dev/null +++ b/etc/cyborg/README.policy.json.txt @@ -0,0 +1,4 @@ +To generate the sample policy.json file, run the following command from the top +level of the cyborg directory: + + tox -egenpolicy diff --git a/etc/cyborg/policy.json b/etc/cyborg/policy.json new file mode 100644 index 00000000..5716f9ed --- /dev/null +++ b/etc/cyborg/policy.json @@ -0,0 +1,4 @@ +# leave this file empty to use default policy defined in code. +{ + +} diff --git a/requirements.txt b/requirements.txt index 21251594..8f55cea5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ oslo.serialization!=2.19.1,>=1.10.0 # Apache-2.0 oslo.db>=4.24.0 # Apache-2.0 oslo.utils>=3.20.0 # Apache-2.0 oslo.versionedobjects>=1.17.0 # Apache-2.0 +oslo.policy>=1.23.0 # Apache-2.0 SQLAlchemy!=1.1.5,!=1.1.6,!=1.1.7,!=1.1.8,>=1.0.10 # MIT alembic>=0.8.10 # MIT stevedore>=1.20.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 5d112f73..2a73394c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,6 +24,9 @@ packages = cyborg [entry_points] +oslo.policy.policies = + cyborg.api = cyborg.common.policy:list_policies + console_scripts = cyborg-api = cyborg.cmd.api:main cyborg-conductor = cyborg.cmd.conductor:main diff --git a/tools/config/cyborg-policy-generator.conf b/tools/config/cyborg-policy-generator.conf new file mode 100644 index 00000000..7c7748ca --- /dev/null +++ b/tools/config/cyborg-policy-generator.conf @@ -0,0 +1,3 @@ +[DEFAULT] +output_file = etc/cyborg/policy.json.sample +namespace = cyborg.api diff --git a/tox.ini b/tox.ini index 087b79c0..556b8f89 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,12 @@ deps = -r{toxinidir}/test-requirements.txt -r{toxinidir}/requirements.txt commands = python setup.py testr --slowest --testr-args='{posargs}' +[testenv:genpolicy] +sitepackages = False +envdir = {toxworkdir}/venv +commands = + oslopolicy-sample-generator --config-file=tools/config/cyborg-policy-generator.conf + [testenv:pep8] commands = flake8 {posargs}