diff --git a/.gitignore b/.gitignore index 0719691..357a1aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build files/join.conf +files/policy.yaml.sample tools/lintstack.head.py *.pyc *.egg-info/ diff --git a/files/policy-generator.conf b/files/policy-generator.conf new file mode 100644 index 0000000..1073b0d --- /dev/null +++ b/files/policy-generator.conf @@ -0,0 +1,3 @@ +[DEFAULT] +output_file = files/policy.yaml.sample +namespace = novajoin diff --git a/novajoin/context.py b/novajoin/context.py index 07690b2..e98752d 100644 --- a/novajoin/context.py +++ b/novajoin/context.py @@ -22,8 +22,6 @@ import copy from oslo_config import cfg from oslo_context import context from oslo_log import log as logging -from oslo_utils import timeutils -import six from novajoin import policy @@ -39,54 +37,9 @@ class RequestContext(context.RequestContext): Represents the user taking a given action within the system. """ - def __init__(self, user_id, project_id, is_admin=None, read_deleted="no", - roles=None, project_name=None, remote_address=None, - timestamp=None, request_id=None, auth_token=None, - overwrite=True, quota_class=None, service_catalog=None, - domain=None, user_domain=None, project_domain=None, - **kwargs): - """Initialize RequestContext. + def __init__(self, *args, **kwargs): - :param read_deleted: 'no' indicates deleted records are hidden, 'yes' - indicates deleted records are visible, 'only' indicates that - *only* deleted records are visible. - - :param overwrite: Set to False to ensure that the greenthread local - copy of the index is not overwritten. - - :param kwargs: Extra arguments that might be present, but we ignore - because they possibly came in from older rpc messages. - """ - - super(RequestContext, self).__init__(auth_token=auth_token, - user=user_id, - tenant=project_id, - domain=domain, - user_domain=user_domain, - project_domain=project_domain, - is_admin=is_admin, - request_id=request_id, - overwrite=overwrite, - roles=roles) - self.project_name = project_name - self.read_deleted = read_deleted - self.remote_address = remote_address - if not timestamp: - timestamp = timeutils.utcnow() - elif isinstance(timestamp, six.string_types): - timestamp = timeutils.parse_isotime(timestamp) - self.timestamp = timestamp - self.quota_class = quota_class - - if service_catalog: - # Only include required parts of service_catalog - self.service_catalog = [s for s in service_catalog - if s.get('type') in - ('identity', 'compute', 'object-store', - 'image')] - else: - # if list is empty or none - self.service_catalog = [] + super(RequestContext, self).__init__(*args, **kwargs) # We need to have RequestContext attributes defined # when policy.check_is_admin invokes request logging @@ -96,39 +49,14 @@ class RequestContext(context.RequestContext): elif self.is_admin and 'admin' not in self.roles: self.roles.append('admin') - def _get_read_deleted(self): - return self._read_deleted - - def _set_read_deleted(self, read_deleted): - if read_deleted not in ('no', 'yes', 'only'): - raise ValueError("read_deleted can only be one of 'no', " - "'yes' or 'only', not %r") % read_deleted - self._read_deleted = read_deleted - - def _del_read_deleted(self): - del self._read_deleted - - read_deleted = property(_get_read_deleted, _set_read_deleted, - _del_read_deleted) - def to_dict(self): result = super(RequestContext, self).to_dict() result['user_id'] = self.user_id + result['user_name'] = self.user_name result['project_id'] = self.project_id result['project_name'] = self.project_name - result['domain'] = self.domain - result['read_deleted'] = self.read_deleted - result['remote_address'] = self.remote_address - result['timestamp'] = self.timestamp.isoformat() - result['quota_class'] = self.quota_class - result['service_catalog'] = self.service_catalog - result['request_id'] = self.request_id return result - @classmethod - def from_dict(cls, values): - return cls(**values) - def elevated(self, read_deleted=None, overwrite=False): """Return a version of this context with admin flag set.""" context = self.deepcopy() @@ -144,25 +72,3 @@ class RequestContext(context.RequestContext): def deepcopy(self): return copy.deepcopy(self) - - # NOTE(sirp): the openstack/common version of RequestContext uses - # tenant/user whereas the Cinder version uses project_id/user_id. - # NOTE(adrienverge): The Cinder version of RequestContext now uses - # tenant/user internally, so it is compatible with context-aware code from - # openstack/common. We still need this shim for the rest of Cinder's - # code. - @property - def project_id(self): - return self.tenant - - @project_id.setter - def project_id(self, value): - self.tenant = value - - @property - def user_id(self): - return self.user - - @user_id.setter - def user_id(self, value): - self.user = value diff --git a/novajoin/exception.py b/novajoin/exception.py index c10744a..d02c9e2 100644 --- a/novajoin/exception.py +++ b/novajoin/exception.py @@ -131,10 +131,6 @@ class GlanceConnectionFailed(JoinException): message = "Connection to glance failed: %(reason)s" -class ImageLimitExceeded(JoinException): - message = "Image quota exceeded" - - class ImageNotAuthorized(JoinException): message = "Not authorized for image %(image_id)s." diff --git a/novajoin/join.py b/novajoin/join.py index 5af53c1..37fa844 100644 --- a/novajoin/join.py +++ b/novajoin/join.py @@ -25,6 +25,7 @@ from novajoin.glance import get_default_image_service from novajoin.ipa import IPAClient from novajoin import keystone_client from novajoin.nova import get_instance +from novajoin import policy from novajoin import util @@ -112,6 +113,12 @@ class JoinController(Controller): LOG.error('No body in create request') raise base.Fault(webob.exc.HTTPBadRequest()) + context = req.environ.get('novajoin.context') + try: + policy.authorize_action(context, 'join:create') + except exception.PolicyNotAuthorized: + raise base.Fault(webob.exc.HTTPForbidden()) + instance_id = body.get('instance-id') image_id = body.get('image-id') project_id = body.get('project-id') @@ -139,7 +146,6 @@ class JoinController(Controller): if enroll.lower() != 'true': LOG.debug('IPA enrollment not requested in instance creation') - context = req.environ.get('novajoin.context') image_service = get_default_image_service() image_metadata = {} try: diff --git a/novajoin/middleware/auth.py b/novajoin/middleware/auth.py index 48de036..c2f2ae3 100644 --- a/novajoin/middleware/auth.py +++ b/novajoin/middleware/auth.py @@ -19,8 +19,6 @@ Simplified Common Auth Middleware from cinder. from oslo_config import cfg from oslo_log import log as logging -from oslo_middleware import request_id -from oslo_serialization import jsonutils import webob.dec import webob.exc @@ -53,48 +51,10 @@ class JoinKeystoneContext(novajoin.base.Middleware): @webob.dec.wsgify(RequestClass=novajoin.base.Request) def __call__(self, req): - user_id = req.headers.get('X_USER') - user_id = req.headers.get('X_USER_ID', user_id) - if user_id is None: - LOG.debug("Neither X_USER_ID nor X_USER found in request") + try: + ctx = context.RequestContext.from_environ(req.environ) + except KeyError: + LOG.debug("Keystone middleware headers not found in request!") return webob.exc.HTTPUnauthorized() - # get the roles - roles = [r.strip() for r in req.headers.get('X_ROLE', '').split(',')] - if 'X_TENANT_ID' in req.headers: - # This is the new header since Keystone went to ID/Name - project_id = req.headers['X_TENANT_ID'] - else: - # This is for legacy compatibility - project_id = req.headers['X_TENANT'] - - project_name = req.headers.get('X_TENANT_NAME') - - req_id = req.environ.get(request_id.ENV_REQUEST_ID) - - # Get the auth token - auth_token = req.headers.get('X_AUTH_TOKEN', - req.headers.get('X_STORAGE_TOKEN')) - - # Build a context, including the auth_token... - remote_address = req.remote_addr - - service_catalog = None - if req.headers.get('X_SERVICE_CATALOG') is not None: - try: - catalog_header = req.headers.get('X_SERVICE_CATALOG') - service_catalog = jsonutils.loads(catalog_header) - except ValueError: - raise webob.exc.HTTPInternalServerError( - explanation='Invalid service catalog json.') - - ctx = context.RequestContext(user_id, - project_id, - project_name=project_name, - roles=roles, - auth_token=auth_token, - remote_address=remote_address, - service_catalog=service_catalog, - request_id=req_id) - req.environ['novajoin.context'] = ctx return self.application diff --git a/novajoin/policy.py b/novajoin/policy.py index f15ceb4..ecc4007 100644 --- a/novajoin/policy.py +++ b/novajoin/policy.py @@ -16,58 +16,81 @@ """Policy Engine""" -from oslo_config import cfg from oslo_policy import opts as policy_opts from oslo_policy import policy +from novajoin import config from novajoin import exception -CONF = cfg.CONF -policy_opts.set_defaults(cfg.CONF, 'policy.json') +CONF = config.CONF +policy_opts.set_defaults(CONF, 'policy.json') _ENFORCER = None +# We have only one endpoint, so there is not a lot of default rules +_RULES = [ + policy.RuleDefault( + 'context_is_admin', 'role:admin', + "Decides what is required for the 'is_admin:True' check to succeed."), + policy.RuleDefault( + 'service_role', 'role:service', + "service role"), + policy.RuleDefault( + 'compute_service_user', 'user_name:nova and rule:service_role', + "This is usualy the nova service user, which calls the novajoin API, " + "configured in [vendordata_dynamic_auth] in nova.conf."), + policy.DocumentedRuleDefault( + 'join:create', 'rule:compute_service_user', + 'Generate the OTP, register it with IPA', + [{'path': '/', 'method': 'POST'}] + ) +] -def init(): + +def list_rules(): + return _RULES + + +def get_enforcer(): global _ENFORCER # pylint: disable=global-statement if not _ENFORCER: _ENFORCER = policy.Enforcer(CONF) + _ENFORCER.register_defaults(list_rules()) + return _ENFORCER -def enforce_action(context, action): +def authorize_action(context, action): """Checks that the action can be done by the given context. - Applies a check to ensure the context's project_id and user_id can be - applied to the given action using the policy enforcement api. + Applies a check to ensure the context's project_id, user_id and others + can be applied to the given action using the policy enforcement api. """ - return enforce(context, action, {'project_id': context.project_id, - 'user_id': context.user_id}) + return authorize(context, action, context.to_dict()) -def enforce(context, action, target): +def authorize(context, action, target): """Verifies that the action is valid on the target in this context. - :param context: cinder context + :param context: novajoin context :param action: string representing the action to be checked this should be colon separated for clarity. i.e. ``compute:create_instance``, ``compute:attach_volume``, ``volume:attach_volume`` - :param object: dictionary representing the object of the action + :param target: dictionary representing the object of the action for object creation this should be a dictionary representing the location of the object e.g. ``{'project_id': context.project_id}`` :raises PolicyNotAuthorized: if verification fails. """ - init() - return _ENFORCER.enforce(action, target, context.to_dict(), - do_raise=True, - exc=exception.PolicyNotAuthorized, - action=action) + return get_enforcer().authorize(action, target, context.to_dict(), + do_raise=True, + exc=exception.PolicyNotAuthorized, + action=action) def check_is_admin(roles, context=None): @@ -76,7 +99,6 @@ def check_is_admin(roles, context=None): Can use roles or user_id from context to determine if user is admin. In a multi-domain configuration, roles alone may not be sufficient. """ - init() # include project_id on target to avoid KeyError if context_is_admin # policy definition is missing, and default admin_or_owner rule @@ -90,4 +112,4 @@ def check_is_admin(roles, context=None): 'user_id': context.user_id } - return _ENFORCER.enforce('context_is_admin', target, credentials) + return get_enforcer().authorize('context_is_admin', target, credentials) diff --git a/novajoin/tests/unit/api/fakes.py b/novajoin/tests/unit/api/fakes.py index 5971f89..599c9ce 100644 --- a/novajoin/tests/unit/api/fakes.py +++ b/novajoin/tests/unit/api/fakes.py @@ -34,11 +34,22 @@ class HTTPRequest(webob.Request): if 'v1' in args[0]: kwargs['base_url'] = 'http://localhost/v1' use_admin_context = kwargs.pop('use_admin_context', False) + use_nova_service_context = kwargs.pop('use_nova_context', True) version = kwargs.pop('version', '1.0') out = base.Request.blank(*args, **kwargs) - out.environ['cinder.context'] = FakeRequestContext( - fake.USER_ID, - fake.PROJECT_ID, - is_admin=use_admin_context) + if use_nova_service_context: + out.environ['novajoin.context'] = FakeRequestContext( + user_id=fake.USER_ID, + user_name='nova', + roles=['service'], + project_id=fake.PROJECT_ID, + is_admin=use_admin_context) + else: + out.environ['novajoin.context'] = FakeRequestContext( + user_id=fake.USER_ID, + user_name='not_nova', + roles=['not_service'], + project_id=fake.PROJECT_ID, + is_admin=use_admin_context) out.api_version_request = Join(version) return out diff --git a/novajoin/tests/unit/api/v1/test_api.py b/novajoin/tests/unit/api/v1/test_api.py index f0b5436..2c5642b 100644 --- a/novajoin/tests/unit/api/v1/test_api.py +++ b/novajoin/tests/unit/api/v1/test_api.py @@ -18,6 +18,7 @@ from oslo_serialization import jsonutils from testtools.matchers import MatchesRegex from novajoin.base import Fault +from novajoin import config from novajoin import join from novajoin import test from novajoin.tests.unit.api import fakes @@ -36,6 +37,7 @@ class JoinTest(test.TestCase): def setUp(self): self.join_controller = join.JoinController() + config.CONF([]) super(JoinTest, self).setUp() def test_no_body(self): @@ -53,6 +55,21 @@ class JoinTest(test.TestCase): else: assert(False) + def test_unauthorized(self): + body = {'test': 'test'} + req = fakes.HTTPRequest.blank('/v1/', use_nova_context=False) + req.method = 'POST' + req.content_type = "application/json" + + # Not using assertRaises because the exception is wrapped as + # a Fault + try: + self.join_controller.create(req, body) + except Fault as fault: + assert fault.status_int == 403 + else: + assert(False) + def test_no_instanceid(self): body = {"metadata": {"ipa_enroll": "True"}, "image-id": fake.IMAGE_ID, diff --git a/setup.cfg b/setup.cfg index 78556a2..a571373 100644 --- a/setup.cfg +++ b/setup.cfg @@ -73,3 +73,9 @@ oslo.config.opts.defaults = console_scripts = novajoin-server = novajoin.wsgi:main novajoin-notify = novajoin.notifications:main + +oslo.policy.policies = + novajoin = novajoin.policy:list_rules + +oslo.policy.enforcer = + novajoin = novajoin.policy:get_enforcer diff --git a/tox.ini b/tox.ini index 4476bcd..b4e2481 100644 --- a/tox.ini +++ b/tox.ini @@ -65,6 +65,7 @@ commands = sphinx-build -W -b html doc/source doc/build/html [testenv:genconfig] sitepackages = False +basepython = python3 envdir = {toxworkdir}/pep8 commands = oslo-config-generator --config-file=files/novajoin-config-generator.conf @@ -86,3 +87,8 @@ setenv = commands = /usr/bin/find . -type f -name "*.py[c|o]" -delete stestr run --slowest {posargs} + +[testenv:genpolicy] +basepython = python3 +envdir = {toxworkdir}/pep8 +commands = oslopolicy-sample-generator --config-file files/policy-generator.conf