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
files/join.conf
files/policy.yaml.sample
tools/lintstack.head.py
*.pyc
*.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_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

View File

@ -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."

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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