Add request context and policy enforcement
this patch introduces an oslo.policy-based API access policy enforcement engine to ironic-inspector. As part of implementation, a proper oslo.context-based request context is also generated and assigned to each request. Short overview of changes: - added custom RequestContext class - extends oslo.context to handle of "is_public_api" flag (False by default) - added context to request in each API route - '/continue' api sets the "is_public_api" flag to True - added documented definitions for API access policies and their defaults - added enforcement of these policies on API requests - added oslo.policy-specific entry points to setup.cfg - added autogenerated policy sample file with defaults - added documentation with autogenerated policies Change-Id: Iff6f98fa9950d78608f0a7c325d132c11a1383b3 Closes-Bug: #1719812
This commit is contained in:
parent
4cecee4bbb
commit
198ef70c2b
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@
|
|||||||
# Sphinx
|
# Sphinx
|
||||||
_build
|
_build
|
||||||
doc/source/contributor/api/
|
doc/source/contributor/api/
|
||||||
|
doc/source/_static/*.sample
|
||||||
|
|
||||||
# release notes build
|
# release notes build
|
||||||
releasenotes/build
|
releasenotes/build
|
||||||
|
@ -10,3 +10,4 @@ namespace = keystonemiddleware.auth_token
|
|||||||
namespace = oslo.db
|
namespace = oslo.db
|
||||||
namespace = oslo.log
|
namespace = oslo.log
|
||||||
namespace = oslo.middleware.cors
|
namespace = oslo.middleware.cors
|
||||||
|
namespace = oslo.policy
|
||||||
|
@ -7,6 +7,8 @@
|
|||||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
extensions = ['sphinx.ext.autodoc',
|
extensions = ['sphinx.ext.autodoc',
|
||||||
'sphinx.ext.viewcode',
|
'sphinx.ext.viewcode',
|
||||||
|
'oslo_policy.sphinxext',
|
||||||
|
'oslo_policy.sphinxpolicygen',
|
||||||
'oslo_config.sphinxext',
|
'oslo_config.sphinxext',
|
||||||
'oslo_config.sphinxconfiggen']
|
'oslo_config.sphinxconfiggen']
|
||||||
|
|
||||||
@ -43,6 +45,9 @@ copyright = u'OpenStack Foundation'
|
|||||||
config_generator_config_file = '../../config-generator.conf'
|
config_generator_config_file = '../../config-generator.conf'
|
||||||
sample_config_basename = '_static/ironic-inspector'
|
sample_config_basename = '_static/ironic-inspector'
|
||||||
|
|
||||||
|
policy_generator_config_file = '../../policy-generator.conf'
|
||||||
|
sample_policy_basename = '_static/ironic-inspector'
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
# |version| and |release|, also used in various other places throughout the
|
# |version| and |release|, also used in various other places throughout the
|
||||||
# built documents.
|
# built documents.
|
||||||
|
@ -9,5 +9,7 @@ file. The overview of configuration file options follow.
|
|||||||
|
|
||||||
Ironic Inspector Configuration Options <ironic-inspector>
|
Ironic Inspector Configuration Options <ironic-inspector>
|
||||||
Sample Ironic Inspector Configuration <sample-config>
|
Sample Ironic Inspector Configuration <sample-config>
|
||||||
|
Policies <policy>
|
||||||
|
Sample policy file <sample-policy>
|
||||||
|
|
||||||
|
|
||||||
|
9
doc/source/configuration/policy.rst
Normal file
9
doc/source/configuration/policy.rst
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
========
|
||||||
|
Policies
|
||||||
|
========
|
||||||
|
|
||||||
|
The following is an overview of all available policies in **ironic inspector**.
|
||||||
|
For a sample configuration file, refer to :doc:`sample-policy`.
|
||||||
|
|
||||||
|
.. show-policy::
|
||||||
|
:config-file: policy-generator.conf
|
13
doc/source/configuration/sample-policy.rst
Normal file
13
doc/source/configuration/sample-policy.rst
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
=======================
|
||||||
|
Ironic Inspector Policy
|
||||||
|
=======================
|
||||||
|
|
||||||
|
The following is a sample **ironic-inspector** policy file, autogenerated from
|
||||||
|
Ironic Inspector when this documentation is built.
|
||||||
|
To avoid issues, make sure your version of **ironic-inspector**
|
||||||
|
matches that of the example policy file.
|
||||||
|
|
||||||
|
The sample policy can also be downloaded as a :download:`file
|
||||||
|
</_static/ironic-inspector.policy.yaml.sample>`.
|
||||||
|
|
||||||
|
.. literalinclude:: /_static/ironic-inspector.policy.yaml.sample
|
21
example.conf
21
example.conf
@ -666,6 +666,27 @@
|
|||||||
#auth_section = <None>
|
#auth_section = <None>
|
||||||
|
|
||||||
|
|
||||||
|
[oslo_policy]
|
||||||
|
|
||||||
|
#
|
||||||
|
# From oslo.policy
|
||||||
|
#
|
||||||
|
|
||||||
|
# The file that defines policies. (string value)
|
||||||
|
#policy_file = policy.json
|
||||||
|
|
||||||
|
# Default rule. Enforced when a requested rule is not found. (string
|
||||||
|
# value)
|
||||||
|
#policy_default_rule = default
|
||||||
|
|
||||||
|
# Directories where policy configuration files are stored. They can be
|
||||||
|
# relative to any directory in the search path defined by the
|
||||||
|
# config_dir option, or absolute paths. The file defined by
|
||||||
|
# policy_file must exist for these directories to be searched.
|
||||||
|
# Missing or empty directories are ignored. (multi valued)
|
||||||
|
#policy_dirs = policy.d
|
||||||
|
|
||||||
|
|
||||||
[pci_devices]
|
[pci_devices]
|
||||||
|
|
||||||
#
|
#
|
||||||
|
45
ironic_inspector/common/context.py
Normal file
45
ironic_inspector/common/context.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#
|
||||||
|
# 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 oslo_context import context
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContext(context.RequestContext):
|
||||||
|
"""Extends security contexts from the oslo.context library."""
|
||||||
|
|
||||||
|
def __init__(self, is_public_api=False, **kwargs):
|
||||||
|
"""Initialize the RequestContext
|
||||||
|
|
||||||
|
:param is_public_api: Specifies whether the request should be processed
|
||||||
|
without authentication.
|
||||||
|
:param kwargs: additional arguments passed to oslo.context.
|
||||||
|
"""
|
||||||
|
super(RequestContext, self).__init__(**kwargs)
|
||||||
|
self.is_public_api = is_public_api
|
||||||
|
|
||||||
|
def to_policy_values(self):
|
||||||
|
policy_values = super(RequestContext, self).to_policy_values()
|
||||||
|
policy_values.update({'is_public_api': self.is_public_api})
|
||||||
|
return policy_values
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, values, **kwargs):
|
||||||
|
kwargs.setdefault('is_public_api', values.get('is_public_api', False))
|
||||||
|
return super(RequestContext, RequestContext).from_dict(values,
|
||||||
|
**kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environ(cls, environ, **kwargs):
|
||||||
|
kwargs.setdefault('is_public_api', environ.get('is_public_api', False))
|
||||||
|
return super(RequestContext, RequestContext).from_environ(environ,
|
||||||
|
**kwargs)
|
@ -21,6 +21,7 @@ from oslo_utils import uuidutils
|
|||||||
import werkzeug
|
import werkzeug
|
||||||
|
|
||||||
from ironic_inspector import api_tools
|
from ironic_inspector import api_tools
|
||||||
|
from ironic_inspector.common import context
|
||||||
from ironic_inspector.common.i18n import _
|
from ironic_inspector.common.i18n import _
|
||||||
from ironic_inspector.common import ironic as ir_utils
|
from ironic_inspector.common import ironic as ir_utils
|
||||||
from ironic_inspector.common import swift
|
from ironic_inspector.common import swift
|
||||||
@ -146,8 +147,44 @@ def generate_introspection_status(node):
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
@app.route('/', methods=['GET'])
|
def api(path, is_public_api=False, rule=None, verb_to_rule_map=None,
|
||||||
@convert_exceptions
|
**flask_kwargs):
|
||||||
|
"""Decorator to wrap api methods.
|
||||||
|
|
||||||
|
Performs flask routing, exception convertion,
|
||||||
|
generation of oslo context for request and API access policy enforcement.
|
||||||
|
|
||||||
|
:param path: flask app route path
|
||||||
|
:param is_public_api: whether this API path should be treated
|
||||||
|
as public, with minimal access enforcement
|
||||||
|
:param rule: API access policy rule to enforce.
|
||||||
|
If rule is None, the 'default' policy rule will be enforced,
|
||||||
|
which is "deny all" if not overridden in policy confif file.
|
||||||
|
:param verb_to_rule_map: if both rule and this are given,
|
||||||
|
defines mapping between http verbs (uppercase)
|
||||||
|
and strings to format the 'rule' string with
|
||||||
|
:param kwargs: all the rest kwargs are passed to flask app.route
|
||||||
|
"""
|
||||||
|
def outer(func):
|
||||||
|
@app.route(path, **flask_kwargs)
|
||||||
|
@convert_exceptions
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
flask.request.context = context.RequestContext.from_environ(
|
||||||
|
flask.request.environ,
|
||||||
|
is_public_api=is_public_api)
|
||||||
|
if verb_to_rule_map and rule:
|
||||||
|
policy_rule = rule.format(
|
||||||
|
verb_to_rule_map[flask.request.method.upper()])
|
||||||
|
else:
|
||||||
|
policy_rule = rule
|
||||||
|
utils.check_auth(flask.request, rule=policy_rule)
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return outer
|
||||||
|
|
||||||
|
|
||||||
|
@api('/', rule='introspection', is_public_api=True, methods=['GET'])
|
||||||
def api_root():
|
def api_root():
|
||||||
versions = [
|
versions = [
|
||||||
{
|
{
|
||||||
@ -163,8 +200,8 @@ def api_root():
|
|||||||
return flask.jsonify(versions=versions)
|
return flask.jsonify(versions=versions)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/<version>', methods=['GET'])
|
@api('/<version>', rule='introspection:version', is_public_api=True,
|
||||||
@convert_exceptions
|
methods=['GET'])
|
||||||
def version_root(version):
|
def version_root(version):
|
||||||
pat = re.compile("^\/%s\/[^\/]*?$" % version)
|
pat = re.compile("^\/%s\/[^\/]*?$" % version)
|
||||||
|
|
||||||
@ -179,8 +216,8 @@ def version_root(version):
|
|||||||
return flask.jsonify(resources=generate_resource_data(resources))
|
return flask.jsonify(resources=generate_resource_data(resources))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/v1/continue', methods=['POST'])
|
@api('/v1/continue', rule="introspection:continue", is_public_api=True,
|
||||||
@convert_exceptions
|
methods=['POST'])
|
||||||
def api_continue():
|
def api_continue():
|
||||||
data = flask.request.get_json(force=True)
|
data = flask.request.get_json(force=True)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@ -196,11 +233,11 @@ def api_continue():
|
|||||||
|
|
||||||
|
|
||||||
# TODO(sambetts) Add API discovery for this endpoint
|
# TODO(sambetts) Add API discovery for this endpoint
|
||||||
@app.route('/v1/introspection/<node_id>', methods=['GET', 'POST'])
|
@api('/v1/introspection/<node_id>',
|
||||||
@convert_exceptions
|
rule="introspection:{}",
|
||||||
|
verb_to_rule_map={'GET': 'status', 'POST': 'start'},
|
||||||
|
methods=['GET', 'POST'])
|
||||||
def api_introspection(node_id):
|
def api_introspection(node_id):
|
||||||
utils.check_auth(flask.request)
|
|
||||||
|
|
||||||
if flask.request.method == 'POST':
|
if flask.request.method == 'POST':
|
||||||
introspect.introspect(node_id,
|
introspect.introspect(node_id,
|
||||||
token=flask.request.headers.get('X-Auth-Token'))
|
token=flask.request.headers.get('X-Auth-Token'))
|
||||||
@ -210,11 +247,8 @@ def api_introspection(node_id):
|
|||||||
return flask.json.jsonify(generate_introspection_status(node_info))
|
return flask.json.jsonify(generate_introspection_status(node_info))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/v1/introspection', methods=['GET'])
|
@api('/v1/introspection', rule='introspection:status', methods=['GET'])
|
||||||
@convert_exceptions
|
|
||||||
def api_introspection_statuses():
|
def api_introspection_statuses():
|
||||||
utils.check_auth(flask.request)
|
|
||||||
|
|
||||||
nodes = node_cache.get_node_list(
|
nodes = node_cache.get_node_list(
|
||||||
marker=api_tools.marker_field(),
|
marker=api_tools.marker_field(),
|
||||||
limit=api_tools.limit_field(default=CONF.api_max_limit)
|
limit=api_tools.limit_field(default=CONF.api_max_limit)
|
||||||
@ -226,19 +260,16 @@ def api_introspection_statuses():
|
|||||||
return flask.json.jsonify(data)
|
return flask.json.jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/v1/introspection/<node_id>/abort', methods=['POST'])
|
@api('/v1/introspection/<node_id>/abort', rule="introspection:abort",
|
||||||
@convert_exceptions
|
methods=['POST'])
|
||||||
def api_introspection_abort(node_id):
|
def api_introspection_abort(node_id):
|
||||||
utils.check_auth(flask.request)
|
|
||||||
introspect.abort(node_id, token=flask.request.headers.get('X-Auth-Token'))
|
introspect.abort(node_id, token=flask.request.headers.get('X-Auth-Token'))
|
||||||
return '', 202
|
return '', 202
|
||||||
|
|
||||||
|
|
||||||
@app.route('/v1/introspection/<node_id>/data', methods=['GET'])
|
@api('/v1/introspection/<node_id>/data', rule="introspection:data",
|
||||||
@convert_exceptions
|
methods=['GET'])
|
||||||
def api_introspection_data(node_id):
|
def api_introspection_data(node_id):
|
||||||
utils.check_auth(flask.request)
|
|
||||||
|
|
||||||
if CONF.processing.store_data == 'swift':
|
if CONF.processing.store_data == 'swift':
|
||||||
if not uuidutils.is_uuid_like(node_id):
|
if not uuidutils.is_uuid_like(node_id):
|
||||||
node = ir_utils.get_node(node_id, fields=['uuid'])
|
node = ir_utils.get_node(node_id, fields=['uuid'])
|
||||||
@ -252,11 +283,9 @@ def api_introspection_data(node_id):
|
|||||||
code=404)
|
code=404)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/v1/introspection/<node_id>/data/unprocessed', methods=['POST'])
|
@api('/v1/introspection/<node_id>/data/unprocessed',
|
||||||
@convert_exceptions
|
rule="introspection:reapply", methods=['POST'])
|
||||||
def api_introspection_reapply(node_id):
|
def api_introspection_reapply(node_id):
|
||||||
utils.check_auth(flask.request)
|
|
||||||
|
|
||||||
if flask.request.content_length:
|
if flask.request.content_length:
|
||||||
return error_response(_('User data processing is not '
|
return error_response(_('User data processing is not '
|
||||||
'supported yet'), code=400)
|
'supported yet'), code=400)
|
||||||
@ -280,11 +309,11 @@ def rule_repr(rule, short):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.route('/v1/rules', methods=['GET', 'POST', 'DELETE'])
|
@api('/v1/rules',
|
||||||
@convert_exceptions
|
rule="introspection:rule:{}",
|
||||||
|
verb_to_rule_map={'GET': 'get', 'POST': 'create', 'DELETE': 'delete'},
|
||||||
|
methods=['GET', 'POST', 'DELETE'])
|
||||||
def api_rules():
|
def api_rules():
|
||||||
utils.check_auth(flask.request)
|
|
||||||
|
|
||||||
if flask.request.method == 'GET':
|
if flask.request.method == 'GET':
|
||||||
res = [rule_repr(rule, short=True) for rule in rules.get_all()]
|
res = [rule_repr(rule, short=True) for rule in rules.get_all()]
|
||||||
return flask.jsonify(rules=res)
|
return flask.jsonify(rules=res)
|
||||||
@ -306,11 +335,11 @@ def api_rules():
|
|||||||
flask.jsonify(rule_repr(rule, short=False)), response_code)
|
flask.jsonify(rule_repr(rule, short=False)), response_code)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/v1/rules/<uuid>', methods=['GET', 'DELETE'])
|
@api('/v1/rules/<uuid>',
|
||||||
@convert_exceptions
|
rule="introspection:rule:{}",
|
||||||
|
verb_to_rule_map={'GET': 'get', 'DELETE': 'delete'},
|
||||||
|
methods=['GET', 'DELETE'])
|
||||||
def api_rule(uuid):
|
def api_rule(uuid):
|
||||||
utils.check_auth(flask.request)
|
|
||||||
|
|
||||||
if flask.request.method == 'GET':
|
if flask.request.method == 'GET':
|
||||||
rule = rules.get(uuid)
|
rule = rules.get(uuid)
|
||||||
return flask.jsonify(rule_repr(rule, short=False))
|
return flask.jsonify(rule_repr(rule, short=False))
|
||||||
|
217
ironic_inspector/policy.py
Normal file
217
ironic_inspector/policy.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from oslo_concurrency import lockutils
|
||||||
|
from oslo_config import cfg
|
||||||
|
from oslo_policy import policy
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
|
||||||
|
_ENFORCER = None
|
||||||
|
|
||||||
|
default_policies = [
|
||||||
|
policy.RuleDefault(
|
||||||
|
'is_admin',
|
||||||
|
'role:admin or role:administrator or role:baremetal_admin',
|
||||||
|
description='Full read/write API access'),
|
||||||
|
policy.RuleDefault(
|
||||||
|
'is_observer',
|
||||||
|
'role:baremetal_observer',
|
||||||
|
description='Read-only API access'),
|
||||||
|
policy.RuleDefault(
|
||||||
|
'public_api',
|
||||||
|
'is_public_api:True',
|
||||||
|
description='Internal flag for public API routes'),
|
||||||
|
policy.RuleDefault(
|
||||||
|
'default',
|
||||||
|
'!',
|
||||||
|
description='Default API access policy'),
|
||||||
|
]
|
||||||
|
|
||||||
|
api_version_policies = [
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection',
|
||||||
|
'rule:public_api',
|
||||||
|
'Access the API root for available versions information',
|
||||||
|
[{'path': '/', 'method': 'GET'}]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:version',
|
||||||
|
'rule:public_api',
|
||||||
|
'Access the versioned API root for version information',
|
||||||
|
[{'path': '/{version}', 'method': 'GET'}]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
introspection_policies = [
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:continue',
|
||||||
|
'rule:public_api',
|
||||||
|
'Ramdisk callback to continue introspection',
|
||||||
|
[{'path': '/continue', 'method': 'POST'}]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:status',
|
||||||
|
'rule:is_admin or rule:is_observer',
|
||||||
|
'Get introspection status',
|
||||||
|
[{'path': '/introspection', 'method': 'GET'},
|
||||||
|
{'path': '/introspection/{node_id}', 'method': 'GET'}]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:start',
|
||||||
|
'rule:is_admin',
|
||||||
|
'Start introspection',
|
||||||
|
[{'path': '/introspection/{node_id}', 'method': 'POST'}]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:abort',
|
||||||
|
'rule:is_admin',
|
||||||
|
'Abort introspection',
|
||||||
|
[{'path': '/introspection/{node_id}/abort', 'method': 'POST'}]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:data',
|
||||||
|
'rule:is_admin',
|
||||||
|
'Get introspection data',
|
||||||
|
[{'path': '/introspection/{node_id}/data', 'method': 'GET'}]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:reapply',
|
||||||
|
'rule:is_admin',
|
||||||
|
'Reapply introspection on stored data',
|
||||||
|
[{'path': '/introspection/{node_id}/data/unprocessed',
|
||||||
|
'method': 'POST'}]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
rule_policies = [
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:rule:get',
|
||||||
|
'rule:is_admin',
|
||||||
|
'Get introspection rule(s)',
|
||||||
|
[{'path': '/rules', 'method': 'GET'},
|
||||||
|
{'path': '/rules/{rule_id}', 'method': 'GET'}]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:rule:delete',
|
||||||
|
'rule:is_admin',
|
||||||
|
'Delete introspection rule(s)',
|
||||||
|
[{'path': '/rules', 'method': 'DELETE'},
|
||||||
|
{'path': '/rules/{rule_id}', 'method': 'DELETE'}]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
'introspection:rule:create',
|
||||||
|
'rule:is_admin',
|
||||||
|
'Create introspection rule',
|
||||||
|
[{'path': '/rules', 'method': 'POST'}]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_policies():
|
||||||
|
"""Get list of all policies defined in code.
|
||||||
|
|
||||||
|
Used to register them all at runtime,
|
||||||
|
and by oslo-config-generator to generate sample policy files.
|
||||||
|
"""
|
||||||
|
policies = itertools.chain(
|
||||||
|
default_policies,
|
||||||
|
api_version_policies,
|
||||||
|
introspection_policies,
|
||||||
|
rule_policies)
|
||||||
|
return policies
|
||||||
|
|
||||||
|
|
||||||
|
@lockutils.synchronized('policy_enforcer')
|
||||||
|
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
|
||||||
|
_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 instance of Policy enforcer."""
|
||||||
|
if not _ENFORCER:
|
||||||
|
init_enforcer()
|
||||||
|
return _ENFORCER
|
||||||
|
|
||||||
|
|
||||||
|
def get_oslo_policy_enforcer():
|
||||||
|
"""Get the enforcer instance to generate policy files.
|
||||||
|
|
||||||
|
This method is for use by oslopolicy CLI scripts.
|
||||||
|
Those scripts need the 'output-file' and 'namespace' options,
|
||||||
|
but having those in sys.argv means loading the inspector config options
|
||||||
|
will fail as those are not expected to be present.
|
||||||
|
So we pass in an arg list with those stripped out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
conf_args = []
|
||||||
|
# Start at 1 because cfg.CONF expects the equivalent of sys.argv[1:]
|
||||||
|
i = 1
|
||||||
|
while i < len(sys.argv):
|
||||||
|
if sys.argv[i].strip('-') in ['namespace', 'output-file']:
|
||||||
|
# e.g. --namespace <somestring>
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
conf_args.append(sys.argv[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
cfg.CONF(conf_args, project='ironic-inspector')
|
||||||
|
|
||||||
|
return get_enforcer()
|
||||||
|
|
||||||
|
|
||||||
|
def authorize(rule, target, creds, *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.
|
||||||
|
args and kwargs are passed directly to oslo.policy Enforcer.authorize
|
||||||
|
Always returns True if CONF.auth_strategy != keystone.
|
||||||
|
|
||||||
|
:param rule: name of a registered oslo.policy rule
|
||||||
|
:param target: dict-like structure to check rule against
|
||||||
|
:param creds: dict of policy values from request
|
||||||
|
:returns: True if request is authorized against given policy,
|
||||||
|
False otherwise
|
||||||
|
:raises: oslo_policy.policy.PolicyNotRegistered if supplied policy
|
||||||
|
is not registered in oslo_policy
|
||||||
|
"""
|
||||||
|
if CONF.auth_strategy != 'keystone':
|
||||||
|
return True
|
||||||
|
enforcer = get_enforcer()
|
||||||
|
rule = CONF.oslo_policy.policy_default_rule if rule is None else rule
|
||||||
|
return enforcer.authorize(rule, target, creds, *args, **kwargs)
|
@ -30,6 +30,7 @@ from ironic_inspector import db
|
|||||||
from ironic_inspector import introspection_state as istate
|
from ironic_inspector import introspection_state as istate
|
||||||
from ironic_inspector import node_cache
|
from ironic_inspector import node_cache
|
||||||
from ironic_inspector.plugins import base as plugins_base
|
from ironic_inspector.plugins import base as plugins_base
|
||||||
|
from ironic_inspector.test.unit import policy_fixture
|
||||||
from ironic_inspector import utils
|
from ironic_inspector import utils
|
||||||
|
|
||||||
CONF = conf.cfg.CONF
|
CONF = conf.cfg.CONF
|
||||||
@ -64,6 +65,7 @@ class BaseTest(test_base.BaseTestCase):
|
|||||||
self.cfg.set_default('slave_connection', None, group='database')
|
self.cfg.set_default('slave_connection', None, group='database')
|
||||||
self.cfg.set_default('max_retries', 10, group='database')
|
self.cfg.set_default('max_retries', 10, group='database')
|
||||||
conf.parse_args([], default_config_files=[])
|
conf.parse_args([], default_config_files=[])
|
||||||
|
self.policy = self.useFixture(policy_fixture.PolicyFixture())
|
||||||
|
|
||||||
def assertPatchEqual(self, expected, actual):
|
def assertPatchEqual(self, expected, actual):
|
||||||
expected = sorted(expected, key=lambda p: p['path'])
|
expected = sorted(expected, key=lambda p: p['path'])
|
||||||
|
40
ironic_inspector/test/unit/policy_fixture.py
Normal file
40
ironic_inspector/test/unit/policy_fixture.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import fixtures
|
||||||
|
from oslo_config import cfg
|
||||||
|
from oslo_policy import opts as policy_opts
|
||||||
|
|
||||||
|
from ironic_inspector import policy as inspector_policy
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
|
||||||
|
policy_data = """{
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyFixture(fixtures.Fixture):
|
||||||
|
def setUp(self):
|
||||||
|
super(PolicyFixture, self).setUp()
|
||||||
|
self.policy_dir = self.useFixture(fixtures.TempDir())
|
||||||
|
self.policy_file_name = os.path.join(self.policy_dir.path,
|
||||||
|
'policy.json')
|
||||||
|
with open(self.policy_file_name, 'w') as policy_file:
|
||||||
|
policy_file.write(policy_data)
|
||||||
|
policy_opts.set_defaults(CONF)
|
||||||
|
CONF.set_override('policy_file', self.policy_file_name, 'oslo_policy')
|
||||||
|
inspector_policy._ENFORCER = None
|
||||||
|
self.addCleanup(inspector_policy.get_enforcer().clear)
|
@ -14,6 +14,7 @@
|
|||||||
from keystonemiddleware import auth_token
|
from keystonemiddleware import auth_token
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
|
|
||||||
|
from ironic_inspector.common import context
|
||||||
from ironic_inspector import node_cache
|
from ironic_inspector import node_cache
|
||||||
from ironic_inspector.test import base
|
from ironic_inspector.test import base
|
||||||
from ironic_inspector import utils
|
from ironic_inspector import utils
|
||||||
@ -30,17 +31,16 @@ CONF = cfg.CONF
|
|||||||
class TestCheckAuth(base.BaseTest):
|
class TestCheckAuth(base.BaseTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestCheckAuth, self).setUp()
|
super(TestCheckAuth, self).setUp()
|
||||||
CONF.set_override('auth_strategy', 'keystone')
|
self.cfg.config(auth_strategy='keystone')
|
||||||
|
|
||||||
@mock.patch.object(auth_token, 'AuthProtocol')
|
@mock.patch.object(auth_token, 'AuthProtocol')
|
||||||
def test_middleware(self, mock_auth):
|
def test_middleware(self, mock_auth):
|
||||||
CONF.set_override('admin_user', 'admin', 'keystone_authtoken')
|
self.cfg.config(group='keystone_authtoken',
|
||||||
CONF.set_override('admin_tenant_name', 'admin', 'keystone_authtoken')
|
admin_user='admin',
|
||||||
CONF.set_override('admin_password', 'password', 'keystone_authtoken')
|
admin_tenant_name='admin',
|
||||||
CONF.set_override('auth_uri', 'http://127.0.0.1:5000',
|
admin_password='password',
|
||||||
'keystone_authtoken')
|
auth_uri='http://127.0.0.1:5000',
|
||||||
CONF.set_override('identity_uri', 'http://127.0.0.1:35357',
|
identity_uri='http://127.0.0.1:35357')
|
||||||
'keystone_authtoken')
|
|
||||||
|
|
||||||
app = mock.Mock(wsgi_app=mock.sentinel.app)
|
app = mock.Mock(wsgi_app=mock.sentinel.app)
|
||||||
utils.add_auth_middleware(app)
|
utils.add_auth_middleware(app)
|
||||||
@ -57,25 +57,31 @@ class TestCheckAuth(base.BaseTest):
|
|||||||
self.assertEqual('http://127.0.0.1:5000', args1['auth_uri'])
|
self.assertEqual('http://127.0.0.1:5000', args1['auth_uri'])
|
||||||
self.assertEqual('http://127.0.0.1:35357', args1['identity_uri'])
|
self.assertEqual('http://127.0.0.1:35357', args1['identity_uri'])
|
||||||
|
|
||||||
def test_ok(self):
|
def test_admin(self):
|
||||||
request = mock.Mock(headers={'X-Identity-Status': 'Confirmed',
|
request = mock.Mock(headers={'X-Identity-Status': 'Confirmed'})
|
||||||
'X-Roles': 'admin,member'})
|
request.context = context.RequestContext(roles=['admin'])
|
||||||
utils.check_auth(request)
|
utils.check_auth(request, rule="is_admin")
|
||||||
|
|
||||||
def test_invalid(self):
|
def test_invalid(self):
|
||||||
request = mock.Mock(headers={'X-Identity-Status': 'Invalid'})
|
request = mock.Mock(headers={'X-Identity-Status': 'Invalid'})
|
||||||
self.assertRaises(utils.Error, utils.check_auth, request)
|
self.assertRaises(utils.Error, utils.check_auth, request)
|
||||||
|
|
||||||
def test_not_admin(self):
|
def test_not_admin(self):
|
||||||
request = mock.Mock(headers={'X-Identity-Status': 'Confirmed',
|
request = mock.Mock(headers={'X-Identity-Status': 'Confirmed'})
|
||||||
'X-Roles': 'member'})
|
request.context = context.RequestContext(roles=['member'])
|
||||||
self.assertRaises(utils.Error, utils.check_auth, request)
|
self.assertRaises(utils.Error, utils.check_auth, request,
|
||||||
|
rule="is_admin")
|
||||||
|
|
||||||
def test_disabled(self):
|
def test_disabled(self):
|
||||||
CONF.set_override('auth_strategy', 'noauth')
|
self.cfg.config(auth_strategy='noauth')
|
||||||
request = mock.Mock(headers={'X-Identity-Status': 'Invalid'})
|
request = mock.Mock(headers={'X-Identity-Status': 'Invalid'})
|
||||||
utils.check_auth(request)
|
utils.check_auth(request)
|
||||||
|
|
||||||
|
def test_public_api(self):
|
||||||
|
request = mock.Mock(headers={'X-Identity-Status': 'Invalid'})
|
||||||
|
request.context = context.RequestContext(is_public_api=True)
|
||||||
|
utils.check_auth(request, "public_api")
|
||||||
|
|
||||||
|
|
||||||
class TestProcessingLogger(base.BaseTest):
|
class TestProcessingLogger(base.BaseTest):
|
||||||
def test_prefix_no_info(self):
|
def test_prefix_no_info(self):
|
||||||
|
@ -24,6 +24,7 @@ import pytz
|
|||||||
|
|
||||||
from ironic_inspector.common.i18n import _
|
from ironic_inspector.common.i18n import _
|
||||||
from ironic_inspector import conf # noqa
|
from ironic_inspector import conf # noqa
|
||||||
|
from ironic_inspector import policy
|
||||||
|
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
|
|
||||||
@ -170,20 +171,21 @@ def add_cors_middleware(app):
|
|||||||
app.wsgi_app = cors_middleware.CORS(app.wsgi_app, CONF)
|
app.wsgi_app = cors_middleware.CORS(app.wsgi_app, CONF)
|
||||||
|
|
||||||
|
|
||||||
def check_auth(request):
|
def check_auth(request, rule=None, target=None):
|
||||||
"""Check authentication on request.
|
"""Check authentication on request.
|
||||||
|
|
||||||
:param request: Flask request
|
:param request: Flask request
|
||||||
|
:param rule: policy rule to check the request against
|
||||||
:raises: utils.Error if access is denied
|
:raises: utils.Error if access is denied
|
||||||
"""
|
"""
|
||||||
if CONF.auth_strategy == 'noauth':
|
if CONF.auth_strategy == 'noauth':
|
||||||
return
|
return
|
||||||
if request.headers.get('X-Identity-Status').lower() == 'invalid':
|
if not request.context.is_public_api:
|
||||||
raise Error(_('Authentication required'), code=401)
|
if request.headers.get('X-Identity-Status', '').lower() == 'invalid':
|
||||||
roles = (request.headers.get('X-Roles') or '').split(',')
|
raise Error(_('Authentication required'), code=401)
|
||||||
if 'admin' not in roles:
|
target = {} if target is None else target
|
||||||
LOG.error('Role "admin" not in user role list %s', roles)
|
if not policy.authorize(rule, target, request.context.to_policy_values()):
|
||||||
raise Error(_('Access denied'), code=403)
|
raise Error(_("Access denied by policy"), code=403)
|
||||||
|
|
||||||
|
|
||||||
def get_valid_macs(data):
|
def get_valid_macs(data):
|
||||||
|
3
policy-generator.conf
Normal file
3
policy-generator.conf
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[DEFAULT]
|
||||||
|
output_file = policy.yaml.sample
|
||||||
|
namespace = ironic_inspector.api
|
59
policy.yaml.sample
Normal file
59
policy.yaml.sample
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Full read/write API access
|
||||||
|
#"is_admin": "role:admin or role:administrator or role:baremetal_admin"
|
||||||
|
|
||||||
|
# Read-only API access
|
||||||
|
#"is_observer": "role:baremetal_observer"
|
||||||
|
|
||||||
|
# Internal flag for public API routes
|
||||||
|
#"public_api": "is_public_api:True"
|
||||||
|
|
||||||
|
# Default API access policy
|
||||||
|
#"default": "!"
|
||||||
|
|
||||||
|
# Access the API root for available versions information
|
||||||
|
# GET /
|
||||||
|
#"introspection": "rule:public_api"
|
||||||
|
|
||||||
|
# Access the versioned API root for version information
|
||||||
|
# GET /{version}
|
||||||
|
#"introspection:version": "rule:public_api"
|
||||||
|
|
||||||
|
# Ramdisk callback to continue introspection
|
||||||
|
# POST /continue
|
||||||
|
#"introspection:continue": "rule:public_api"
|
||||||
|
|
||||||
|
# Get introspection status
|
||||||
|
# GET /introspection
|
||||||
|
# GET /introspection/{node_id}
|
||||||
|
#"introspection:status": "rule:is_admin or rule:is_observer"
|
||||||
|
|
||||||
|
# Start introspection
|
||||||
|
# POST /introspection/{node_id}
|
||||||
|
#"introspection:start": "rule:is_admin"
|
||||||
|
|
||||||
|
# Abort introspection
|
||||||
|
# POST /introspection/{node_id}/abort
|
||||||
|
#"introspection:abort": "rule:is_admin"
|
||||||
|
|
||||||
|
# Get introspection data
|
||||||
|
# GET /introspection/{node_id}/data
|
||||||
|
#"introspection:data": "rule:is_admin"
|
||||||
|
|
||||||
|
# Reapply introspection on stored data
|
||||||
|
# POST /introspection/{node_id}/data/unprocessed
|
||||||
|
#"introspection:reapply": "rule:is_admin"
|
||||||
|
|
||||||
|
# Get introspection rule(s)
|
||||||
|
# GET /rules
|
||||||
|
# GET /rules/{rule_id}
|
||||||
|
#"introspection:rule:get": "rule:is_admin"
|
||||||
|
|
||||||
|
# Delete introspection rule(s)
|
||||||
|
# DELETE /rules
|
||||||
|
# DELETE /rules/{rule_id}
|
||||||
|
#"introspection:rule:delete": "rule:is_admin"
|
||||||
|
|
||||||
|
# Create introspection rule
|
||||||
|
# POST /rules
|
||||||
|
#"introspection:rule:create": "rule:is_admin"
|
||||||
|
|
35
releasenotes/notes/policy-engine-c44828e3131e6c62.yaml
Normal file
35
releasenotes/notes/policy-engine-c44828e3131e6c62.yaml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
Added an API access policy enforcment (based on oslo.policy rules).
|
||||||
|
Similar to other OpenStack services, operators now can configure
|
||||||
|
fine-grained access policies using ``policy.yaml`` file.
|
||||||
|
See example ``policy.yaml.sample`` file included in the code tree
|
||||||
|
for the list of available policies and their default rules.
|
||||||
|
This file can also be generated from the code tree
|
||||||
|
with ``tox -egenpolicy`` command.
|
||||||
|
|
||||||
|
See ``oslo.policy`` package documentation for more information
|
||||||
|
on using and configuring API access policies.
|
||||||
|
|
||||||
|
upgrade:
|
||||||
|
- |
|
||||||
|
Due to the choice of default values for API access policies rules,
|
||||||
|
some API parts of the ironic-inspector service will become available
|
||||||
|
to wider range of users after upgrade:
|
||||||
|
|
||||||
|
- general access to the whole API is by default granted to a user
|
||||||
|
with either ``admin``, ``administrator`` or ``baremetal_admin``
|
||||||
|
role (previously it allowed access only to a user with ``admin``
|
||||||
|
role)
|
||||||
|
- listing of current introspections and showing a given
|
||||||
|
introspection is by default also allowed to the user with the
|
||||||
|
``baremetal_observer`` role
|
||||||
|
|
||||||
|
If these access policies are not suiting a given deployment before
|
||||||
|
upgrade, operator will have to create a ``policy.json`` file
|
||||||
|
in the inspector configuration folder (usually ``/etc/inspector``)
|
||||||
|
that redefines the API rules as required.
|
||||||
|
|
||||||
|
See ``oslo.policy`` package documentation for more information
|
||||||
|
on using and configuring API access policies.
|
@ -20,10 +20,12 @@ python-swiftclient>=3.2.0 # Apache-2.0
|
|||||||
pytz>=2013.6 # MIT
|
pytz>=2013.6 # MIT
|
||||||
oslo.concurrency>=3.20.0 # Apache-2.0
|
oslo.concurrency>=3.20.0 # Apache-2.0
|
||||||
oslo.config>=4.6.0 # Apache-2.0
|
oslo.config>=4.6.0 # Apache-2.0
|
||||||
|
oslo.context>=2.14.0,!=2.19.1 # Apache-2.0
|
||||||
oslo.db>=4.27.0 # Apache-2.0
|
oslo.db>=4.27.0 # Apache-2.0
|
||||||
oslo.i18n>=3.15.3 # Apache-2.0
|
oslo.i18n>=3.15.3 # Apache-2.0
|
||||||
oslo.log>=3.30.0 # Apache-2.0
|
oslo.log>=3.30.0 # Apache-2.0
|
||||||
oslo.middleware>=3.31.0 # Apache-2.0
|
oslo.middleware>=3.31.0 # Apache-2.0
|
||||||
|
oslo.policy>=1.23.0 # Apache-2.0
|
||||||
oslo.rootwrap>=5.8.0 # Apache-2.0
|
oslo.rootwrap>=5.8.0 # Apache-2.0
|
||||||
oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0
|
oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0
|
||||||
oslo.utils>=3.28.0 # Apache-2.0
|
oslo.utils>=3.28.0 # Apache-2.0
|
||||||
|
@ -68,6 +68,10 @@ oslo.config.opts =
|
|||||||
ironic_inspector.plugins.pci_devices = ironic_inspector.plugins.pci_devices:list_opts
|
ironic_inspector.plugins.pci_devices = ironic_inspector.plugins.pci_devices:list_opts
|
||||||
oslo.config.opts.defaults =
|
oslo.config.opts.defaults =
|
||||||
ironic_inspector = ironic_inspector.conf:set_config_defaults
|
ironic_inspector = ironic_inspector.conf:set_config_defaults
|
||||||
|
oslo.policy.enforcer =
|
||||||
|
ironic_inspector = ironic_inspector.policy:get_oslo_policy_enforcer
|
||||||
|
oslo.policy.policies =
|
||||||
|
ironic_inspector.api = ironic_inspector.policy:list_policies
|
||||||
|
|
||||||
tempest.test_plugins =
|
tempest.test_plugins =
|
||||||
ironic_inspector_tests = ironic_inspector.test.inspector_tempest_plugin.plugin:InspectorTempestPlugin
|
ironic_inspector_tests = ironic_inspector.test.inspector_tempest_plugin.plugin:InspectorTempestPlugin
|
||||||
|
5
tox.ini
5
tox.ini
@ -54,6 +54,11 @@ commands =
|
|||||||
envdir = {toxworkdir}/venv
|
envdir = {toxworkdir}/venv
|
||||||
commands = oslo-config-generator --config-file config-generator.conf
|
commands = oslo-config-generator --config-file config-generator.conf
|
||||||
|
|
||||||
|
[testenv:genpolicy]
|
||||||
|
sitepackages = False
|
||||||
|
envdir = {toxworkdir}/venv
|
||||||
|
commands = oslopolicy-sample-generator --config-file {toxinidir}/policy-generator.conf
|
||||||
|
|
||||||
[testenv:genstates]
|
[testenv:genstates]
|
||||||
deps = {[testenv]deps}
|
deps = {[testenv]deps}
|
||||||
commands = {toxinidir}/tools/states_to_dot.py -f {toxinidir}/doc/source/images/states.svg --format svg
|
commands = {toxinidir}/tools/states_to_dot.py -f {toxinidir}/doc/source/images/states.svg --format svg
|
||||||
|
Loading…
Reference in New Issue
Block a user