Check policy when handling a HTTP request

* Add default policy for handling the create request.
* Allow it to be accessed only by nova service.
* Remove unused code copied from cinder.

Change-Id: Ieaa407f27c6774d1fd17850a9571de5554360bae
This commit is contained in:
Grzegorz Grasza 2019-01-16 15:49:26 +01:00
parent b3f961e331
commit 462305315c
11 changed files with 103 additions and 169 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
build build
files/join.conf files/join.conf
files/policy.yaml.sample
tools/lintstack.head.py tools/lintstack.head.py
*.pyc *.pyc
*.egg-info/ *.egg-info/

View File

@ -0,0 +1,3 @@
[DEFAULT]
output_file = files/policy.yaml.sample
namespace = novajoin

View File

@ -22,8 +22,6 @@ import copy
from oslo_config import cfg from oslo_config import cfg
from oslo_context import context from oslo_context import context
from oslo_log import log as logging from oslo_log import log as logging
from oslo_utils import timeutils
import six
from novajoin import policy from novajoin import policy
@ -39,54 +37,9 @@ class RequestContext(context.RequestContext):
Represents the user taking a given action within the system. Represents the user taking a given action within the system.
""" """
def __init__(self, user_id, project_id, is_admin=None, read_deleted="no", def __init__(self, *args, **kwargs):
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.
:param read_deleted: 'no' indicates deleted records are hidden, 'yes' super(RequestContext, self).__init__(*args, **kwargs)
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 = []
# We need to have RequestContext attributes defined # We need to have RequestContext attributes defined
# when policy.check_is_admin invokes request logging # 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: elif self.is_admin and 'admin' not in self.roles:
self.roles.append('admin') 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): def to_dict(self):
result = super(RequestContext, self).to_dict() result = super(RequestContext, self).to_dict()
result['user_id'] = self.user_id result['user_id'] = self.user_id
result['user_name'] = self.user_name
result['project_id'] = self.project_id result['project_id'] = self.project_id
result['project_name'] = self.project_name 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 return result
@classmethod
def from_dict(cls, values):
return cls(**values)
def elevated(self, read_deleted=None, overwrite=False): def elevated(self, read_deleted=None, overwrite=False):
"""Return a version of this context with admin flag set.""" """Return a version of this context with admin flag set."""
context = self.deepcopy() context = self.deepcopy()
@ -144,25 +72,3 @@ class RequestContext(context.RequestContext):
def deepcopy(self): def deepcopy(self):
return copy.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

View File

@ -131,10 +131,6 @@ class GlanceConnectionFailed(JoinException):
message = "Connection to glance failed: %(reason)s" message = "Connection to glance failed: %(reason)s"
class ImageLimitExceeded(JoinException):
message = "Image quota exceeded"
class ImageNotAuthorized(JoinException): class ImageNotAuthorized(JoinException):
message = "Not authorized for image %(image_id)s." message = "Not authorized for image %(image_id)s."

View File

@ -25,6 +25,7 @@ from novajoin.glance import get_default_image_service
from novajoin.ipa import IPAClient from novajoin.ipa import IPAClient
from novajoin import keystone_client from novajoin import keystone_client
from novajoin.nova import get_instance from novajoin.nova import get_instance
from novajoin import policy
from novajoin import util from novajoin import util
@ -112,6 +113,12 @@ class JoinController(Controller):
LOG.error('No body in create request') LOG.error('No body in create request')
raise base.Fault(webob.exc.HTTPBadRequest()) 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') instance_id = body.get('instance-id')
image_id = body.get('image-id') image_id = body.get('image-id')
project_id = body.get('project-id') project_id = body.get('project-id')
@ -139,7 +146,6 @@ class JoinController(Controller):
if enroll.lower() != 'true': if enroll.lower() != 'true':
LOG.debug('IPA enrollment not requested in instance creation') LOG.debug('IPA enrollment not requested in instance creation')
context = req.environ.get('novajoin.context')
image_service = get_default_image_service() image_service = get_default_image_service()
image_metadata = {} image_metadata = {}
try: try:

View File

@ -19,8 +19,6 @@ Simplified Common Auth Middleware from cinder.
from oslo_config import cfg from oslo_config import cfg
from oslo_log import log as logging from oslo_log import log as logging
from oslo_middleware import request_id
from oslo_serialization import jsonutils
import webob.dec import webob.dec
import webob.exc import webob.exc
@ -53,48 +51,10 @@ class JoinKeystoneContext(novajoin.base.Middleware):
@webob.dec.wsgify(RequestClass=novajoin.base.Request) @webob.dec.wsgify(RequestClass=novajoin.base.Request)
def __call__(self, req): def __call__(self, req):
user_id = req.headers.get('X_USER') try:
user_id = req.headers.get('X_USER_ID', user_id) ctx = context.RequestContext.from_environ(req.environ)
if user_id is None: except KeyError:
LOG.debug("Neither X_USER_ID nor X_USER found in request") LOG.debug("Keystone middleware headers not found in request!")
return webob.exc.HTTPUnauthorized() 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 req.environ['novajoin.context'] = ctx
return self.application return self.application

View File

@ -16,58 +16,81 @@
"""Policy Engine""" """Policy Engine"""
from oslo_config import cfg
from oslo_policy import opts as policy_opts from oslo_policy import opts as policy_opts
from oslo_policy import policy from oslo_policy import policy
from novajoin import config
from novajoin import exception from novajoin import exception
CONF = cfg.CONF CONF = config.CONF
policy_opts.set_defaults(cfg.CONF, 'policy.json') policy_opts.set_defaults(CONF, 'policy.json')
_ENFORCER = None _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 global _ENFORCER # pylint: disable=global-statement
if not _ENFORCER: if not _ENFORCER:
_ENFORCER = policy.Enforcer(CONF) _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. """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 Applies a check to ensure the context's project_id, user_id and others
applied to the given action using the policy enforcement api. can be applied to the given action using the policy enforcement api.
""" """
return enforce(context, action, {'project_id': context.project_id, return authorize(context, action, context.to_dict())
'user_id': context.user_id})
def enforce(context, action, target): def authorize(context, action, target):
"""Verifies that the action is valid on the target in this context. """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 :param action: string representing the action to be checked
this should be colon separated for clarity. this should be colon separated for clarity.
i.e. ``compute:create_instance``, i.e. ``compute:create_instance``,
``compute:attach_volume``, ``compute:attach_volume``,
``volume: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 for object creation this should be a dictionary representing the
location of the object e.g. ``{'project_id': context.project_id}`` location of the object e.g. ``{'project_id': context.project_id}``
:raises PolicyNotAuthorized: if verification fails. :raises PolicyNotAuthorized: if verification fails.
""" """
init()
return _ENFORCER.enforce(action, target, context.to_dict(), return get_enforcer().authorize(action, target, context.to_dict(),
do_raise=True, do_raise=True,
exc=exception.PolicyNotAuthorized, exc=exception.PolicyNotAuthorized,
action=action) action=action)
def check_is_admin(roles, context=None): 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. 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. In a multi-domain configuration, roles alone may not be sufficient.
""" """
init()
# include project_id on target to avoid KeyError if context_is_admin # include project_id on target to avoid KeyError if context_is_admin
# policy definition is missing, and default admin_or_owner rule # 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 'user_id': context.user_id
} }
return _ENFORCER.enforce('context_is_admin', target, credentials) return get_enforcer().authorize('context_is_admin', target, credentials)

View File

@ -34,11 +34,22 @@ class HTTPRequest(webob.Request):
if 'v1' in args[0]: if 'v1' in args[0]:
kwargs['base_url'] = 'http://localhost/v1' kwargs['base_url'] = 'http://localhost/v1'
use_admin_context = kwargs.pop('use_admin_context', False) 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') version = kwargs.pop('version', '1.0')
out = base.Request.blank(*args, **kwargs) out = base.Request.blank(*args, **kwargs)
out.environ['cinder.context'] = FakeRequestContext( if use_nova_service_context:
fake.USER_ID, out.environ['novajoin.context'] = FakeRequestContext(
fake.PROJECT_ID, user_id=fake.USER_ID,
is_admin=use_admin_context) 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) out.api_version_request = Join(version)
return out return out

View File

@ -18,6 +18,7 @@ from oslo_serialization import jsonutils
from testtools.matchers import MatchesRegex from testtools.matchers import MatchesRegex
from novajoin.base import Fault from novajoin.base import Fault
from novajoin import config
from novajoin import join from novajoin import join
from novajoin import test from novajoin import test
from novajoin.tests.unit.api import fakes from novajoin.tests.unit.api import fakes
@ -36,6 +37,7 @@ class JoinTest(test.TestCase):
def setUp(self): def setUp(self):
self.join_controller = join.JoinController() self.join_controller = join.JoinController()
config.CONF([])
super(JoinTest, self).setUp() super(JoinTest, self).setUp()
def test_no_body(self): def test_no_body(self):
@ -53,6 +55,21 @@ class JoinTest(test.TestCase):
else: else:
assert(False) 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): def test_no_instanceid(self):
body = {"metadata": {"ipa_enroll": "True"}, body = {"metadata": {"ipa_enroll": "True"},
"image-id": fake.IMAGE_ID, "image-id": fake.IMAGE_ID,

View File

@ -73,3 +73,9 @@ oslo.config.opts.defaults =
console_scripts = console_scripts =
novajoin-server = novajoin.wsgi:main novajoin-server = novajoin.wsgi:main
novajoin-notify = novajoin.notifications:main novajoin-notify = novajoin.notifications:main
oslo.policy.policies =
novajoin = novajoin.policy:list_rules
oslo.policy.enforcer =
novajoin = novajoin.policy:get_enforcer

View File

@ -65,6 +65,7 @@ commands = sphinx-build -W -b html doc/source doc/build/html
[testenv:genconfig] [testenv:genconfig]
sitepackages = False sitepackages = False
basepython = python3
envdir = {toxworkdir}/pep8 envdir = {toxworkdir}/pep8
commands = oslo-config-generator --config-file=files/novajoin-config-generator.conf commands = oslo-config-generator --config-file=files/novajoin-config-generator.conf
@ -86,3 +87,8 @@ setenv =
commands = commands =
/usr/bin/find . -type f -name "*.py[c|o]" -delete /usr/bin/find . -type f -name "*.py[c|o]" -delete
stestr run --slowest {posargs} stestr run --slowest {posargs}
[testenv:genpolicy]
basepython = python3
envdir = {toxworkdir}/pep8
commands = oslopolicy-sample-generator --config-file files/policy-generator.conf