Merge "Register and document policy in code"
This commit is contained in:
commit
6323ed802c
|
@ -36,3 +36,6 @@ AUTHORS
|
|||
ChangeLog
|
||||
|
||||
.testrepository
|
||||
|
||||
# generated policy file
|
||||
etc/blazar/policy.yaml.sample
|
|
@ -27,7 +27,7 @@ class API(object):
|
|||
"""List all existing computehosts."""
|
||||
return self.manager_rpcapi.list_computehosts()
|
||||
|
||||
@policy.authorize('oshosts', 'create')
|
||||
@policy.authorize('oshosts', 'post')
|
||||
@trusts.use_trust_auth()
|
||||
def create_computehost(self, data):
|
||||
"""Create new computehost.
|
||||
|
@ -47,7 +47,7 @@ class API(object):
|
|||
"""
|
||||
return self.manager_rpcapi.get_computehost(host_id)
|
||||
|
||||
@policy.authorize('oshosts', 'update')
|
||||
@policy.authorize('oshosts', 'put')
|
||||
def update_computehost(self, host_id, data):
|
||||
"""Update computehost. Only name changing may be proceeded.
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ class API(object):
|
|||
project_id = ctx.project_id
|
||||
return self.manager_rpcapi.list_leases(project_id=project_id)
|
||||
|
||||
@policy.authorize('leases', 'create')
|
||||
@policy.authorize('leases', 'post')
|
||||
@trusts.use_trust_auth()
|
||||
def create_lease(self, data):
|
||||
"""Create new lease.
|
||||
|
@ -64,7 +64,7 @@ class API(object):
|
|||
"""
|
||||
return self.manager_rpcapi.get_lease(lease_id)
|
||||
|
||||
@policy.authorize('leases', 'update')
|
||||
@policy.authorize('leases', 'put')
|
||||
def update_lease(self, lease_id, data):
|
||||
"""Update lease.
|
||||
|
||||
|
|
|
@ -126,7 +126,7 @@ class HostsController(extensions.BaseController):
|
|||
for host in
|
||||
pecan.request.hosts_rpcapi.list_computehosts()]
|
||||
|
||||
@policy.authorize('oshosts', 'create')
|
||||
@policy.authorize('oshosts', 'post')
|
||||
@wsme_pecan.wsexpose(Host, body=Host, status_code=202)
|
||||
@trusts.use_trust_auth()
|
||||
def post(self, host):
|
||||
|
@ -144,7 +144,7 @@ class HostsController(extensions.BaseController):
|
|||
else:
|
||||
raise exceptions.BlazarException(_("Host can't be created"))
|
||||
|
||||
@policy.authorize('oshosts', 'update')
|
||||
@policy.authorize('oshosts', 'put')
|
||||
@wsme_pecan.wsexpose(Host, types.IntegerType(), body=Host,
|
||||
status_code=202)
|
||||
def put(self, id, host):
|
||||
|
|
|
@ -103,7 +103,7 @@ class LeasesController(extensions.BaseController):
|
|||
return [Lease.convert(lease)
|
||||
for lease in pecan.request.rpcapi.list_leases()]
|
||||
|
||||
@policy.authorize('leases', 'create')
|
||||
@policy.authorize('leases', 'post')
|
||||
@wsme_pecan.wsexpose(Lease, body=Lease, status_code=202)
|
||||
@trusts.use_trust_auth()
|
||||
def post(self, lease):
|
||||
|
@ -120,7 +120,7 @@ class LeasesController(extensions.BaseController):
|
|||
else:
|
||||
raise exceptions.BlazarException(_("Lease can't be created"))
|
||||
|
||||
@policy.authorize('leases', 'update')
|
||||
@policy.authorize('leases', 'put')
|
||||
@wsme_pecan.wsexpose(Lease, types.UuidType(), body=Lease, status_code=202)
|
||||
def put(self, id, sublease):
|
||||
"""Update an existing lease.
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# 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
|
||||
|
||||
from blazar.policies import base
|
||||
from blazar.policies import leases
|
||||
from blazar.policies import oshosts
|
||||
|
||||
|
||||
def list_rules():
|
||||
return itertools.chain(
|
||||
base.list_rules(),
|
||||
leases.list_rules(),
|
||||
oshosts.list_rules(),
|
||||
)
|
|
@ -0,0 +1,32 @@
|
|||
# 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_policy import policy
|
||||
|
||||
RULE_ADMIN = 'rule:admin'
|
||||
RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner'
|
||||
RULE_ANY = '@'
|
||||
|
||||
rules = [
|
||||
policy.RuleDefault(
|
||||
name="admin",
|
||||
check_str="is_admin:True or role:admin",
|
||||
description="Default rule for most Admin APIs."),
|
||||
policy.RuleDefault(
|
||||
name="admin_or_owner",
|
||||
check_str="rule:admin or project_id:%(project_id)s",
|
||||
description="Default rule for most non-Admin APIs.")
|
||||
]
|
||||
|
||||
|
||||
def list_rules():
|
||||
return rules
|
|
@ -0,0 +1,72 @@
|
|||
# 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_policy import policy
|
||||
|
||||
from blazar.policies import base
|
||||
|
||||
POLICY_ROOT = 'blazar:leases:%s'
|
||||
|
||||
leases_policies = [
|
||||
policy.DocumentedRuleDefault(
|
||||
name=POLICY_ROOT % 'get',
|
||||
check_str=base.RULE_ADMIN_OR_OWNER,
|
||||
description='Policy rule for List/Show Lease(s) API.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/{api_version}/leases',
|
||||
'method': 'GET'
|
||||
},
|
||||
{
|
||||
'path': '/{api_version}/leases/{lease_id}',
|
||||
'method': 'GET'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=POLICY_ROOT % 'post',
|
||||
check_str=base.RULE_ADMIN_OR_OWNER,
|
||||
description='Policy rule for Create Lease API.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/{api_version}/leases',
|
||||
'method': 'POST'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=POLICY_ROOT % 'put',
|
||||
check_str=base.RULE_ADMIN_OR_OWNER,
|
||||
description='Policy rule for Update Lease API.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/{api_version}/leases/{lease_id}',
|
||||
'method': 'PUT'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=POLICY_ROOT % 'delete',
|
||||
check_str=base.RULE_ADMIN_OR_OWNER,
|
||||
description='Policy rule for Delete Lease API.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/{api_version}/leases/{lease_id}',
|
||||
'method': 'DELETE'
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def list_rules():
|
||||
return leases_policies
|
|
@ -0,0 +1,72 @@
|
|||
# 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_policy import policy
|
||||
|
||||
from blazar.policies import base
|
||||
|
||||
POLICY_ROOT = 'blazar:oshosts:%s'
|
||||
|
||||
oshosts_policies = [
|
||||
policy.DocumentedRuleDefault(
|
||||
name=POLICY_ROOT % 'get',
|
||||
check_str=base.RULE_ADMIN,
|
||||
description='Policy rule for List/Show Host(s) API.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/{api_version}/os-hosts',
|
||||
'method': 'GET'
|
||||
},
|
||||
{
|
||||
'path': '/{api_version}/os-hosts/{host_id}',
|
||||
'method': 'GET'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=POLICY_ROOT % 'post',
|
||||
check_str=base.RULE_ADMIN,
|
||||
description='Policy rule for Create Host API.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/{api_version}/os-hosts',
|
||||
'method': 'POST'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=POLICY_ROOT % 'put',
|
||||
check_str=base.RULE_ADMIN,
|
||||
description='Policy rule for Update Host API.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/{api_version}/os-hosts/{host_id}',
|
||||
'method': 'PUT'
|
||||
}
|
||||
]
|
||||
),
|
||||
policy.DocumentedRuleDefault(
|
||||
name=POLICY_ROOT % 'delete',
|
||||
check_str=base.RULE_ADMIN,
|
||||
description='Policy rule for Delete Host API.',
|
||||
operations=[
|
||||
{
|
||||
'path': '/{api_version}/os-hosts/{host_id}',
|
||||
'method': 'DELETE'
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def list_rules():
|
||||
return oshosts_policies
|
|
@ -24,6 +24,7 @@ from oslo_policy import policy
|
|||
|
||||
from blazar import context
|
||||
from blazar import exceptions
|
||||
from blazar import policies
|
||||
|
||||
CONF = cfg.CONF
|
||||
opts.set_defaults(CONF)
|
||||
|
@ -45,6 +46,7 @@ def init():
|
|||
if not _ENFORCER:
|
||||
LOG.debug("Enforcer not present, recreating at init stage.")
|
||||
_ENFORCER = policy.Enforcer(CONF)
|
||||
_ENFORCER.register_defaults(policies.list_rules())
|
||||
|
||||
|
||||
def set_rules(data, default_rule=None):
|
||||
|
|
|
@ -13,8 +13,6 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
|
||||
import fixtures
|
||||
import tempfile
|
||||
import testscenarios
|
||||
|
@ -26,8 +24,6 @@ from oslotest import base
|
|||
from blazar import context
|
||||
from blazar.db.sqlalchemy import api as db_api
|
||||
from blazar.db.sqlalchemy import facade_wrapper
|
||||
from blazar import policy
|
||||
from blazar.tests import fake_policy
|
||||
|
||||
cfg.CONF.set_override('use_stderr', False)
|
||||
|
||||
|
@ -52,22 +48,6 @@ class Database(fixtures.Fixture):
|
|||
self.addCleanup(db_api.drop_db)
|
||||
|
||||
|
||||
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(fake_policy.policy_data)
|
||||
cfg.CONF.set_override('policy_file', self.policy_file_name,
|
||||
group='oslo_policy')
|
||||
policy.reset()
|
||||
policy.init()
|
||||
self.addCleanup(policy.reset)
|
||||
|
||||
|
||||
class TestCase(testscenarios.WithScenarios, base.BaseTestCase):
|
||||
"""Test case base class for all unit tests.
|
||||
|
||||
|
@ -80,7 +60,6 @@ class TestCase(testscenarios.WithScenarios, base.BaseTestCase):
|
|||
super(TestCase, self).setUp()
|
||||
self.context_mock = None
|
||||
cfg.CONF(args=[], project='blazar')
|
||||
self.policy = self.useFixture(PolicyFixture())
|
||||
|
||||
def patch(self, obj, attr):
|
||||
"""Returns a Mocked object on the patched attribute."""
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
# Copyright (c) 2013 Bull.
|
||||
#
|
||||
# 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_data = """
|
||||
{
|
||||
|
||||
"admin": "is_admin:True or role:admin or role:masterofuniverse",
|
||||
"admin_or_owner": "rule:admin or project_id:%(project_id)s",
|
||||
"default": "!",
|
||||
|
||||
"admin_api": "rule:admin",
|
||||
"blazar:leases": "rule:admin_or_owner",
|
||||
"blazar:oshosts": "rule:admin_api",
|
||||
|
||||
"blazar:leases:get": "rule:admin_or_owner",
|
||||
"blazar:leases:create": "rule:admin_or_owner",
|
||||
"blazar:leases:delete": "rule:admin_or_owner",
|
||||
"blazar:leases:update": "rule:admin_or_owner",
|
||||
|
||||
"blazar:plugins:get": "@",
|
||||
|
||||
"blazar:oshosts:get": "rule:admin_api",
|
||||
"blazar:oshosts:create": "rule:admin_api",
|
||||
"blazar:oshosts:delete": "rule:admin_api",
|
||||
"blazar:oshosts:update": "rule:admin_api"
|
||||
}
|
||||
"""
|
|
@ -39,7 +39,7 @@ class BlazarPolicyTestCase(tests.TestCase):
|
|||
'project_id': self.context.project_id}
|
||||
target_wrong = {'user_id': self.context.user_id,
|
||||
'project_id': 'bad_project'}
|
||||
action = "blazar:leases"
|
||||
action = "blazar:leases:get"
|
||||
self.assertTrue(policy.enforce(self.context, action,
|
||||
target_good))
|
||||
self.assertFalse(policy.enforce(self.context, action,
|
||||
|
@ -48,14 +48,14 @@ class BlazarPolicyTestCase(tests.TestCase):
|
|||
def test_adminpolicy(self):
|
||||
target = {'user_id': self.context.user_id,
|
||||
'project_id': self.context.project_id}
|
||||
action = "blazar:oshosts"
|
||||
action = "blazar:oshosts:get"
|
||||
self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce,
|
||||
self.context, action, target)
|
||||
|
||||
def test_elevatedpolicy(self):
|
||||
target = {'user_id': self.context.user_id,
|
||||
'project_id': self.context.project_id}
|
||||
action = "blazar:oshosts"
|
||||
action = "blazar:oshosts:get"
|
||||
self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce,
|
||||
self.context, action, target)
|
||||
elevated_context = self.context.elevated()
|
||||
|
@ -63,23 +63,14 @@ class BlazarPolicyTestCase(tests.TestCase):
|
|||
|
||||
def test_authorize(self):
|
||||
|
||||
@policy.authorize('leases', ctx=self.context)
|
||||
def user_method(self):
|
||||
return True
|
||||
|
||||
@policy.authorize('leases', 'get', ctx=self.context)
|
||||
def user_method_with_action(self):
|
||||
return True
|
||||
|
||||
@policy.authorize('oshosts', ctx=self.context)
|
||||
def adminonly_method(self):
|
||||
@policy.authorize('oshosts', 'get', ctx=self.context)
|
||||
def adminonly_method_with_action(self):
|
||||
return True
|
||||
|
||||
self.assertTrue(user_method(self))
|
||||
self.assertTrue(user_method_with_action(self))
|
||||
try:
|
||||
adminonly_method(self)
|
||||
self.assertTrue(False)
|
||||
except exceptions.PolicyNotAuthorized:
|
||||
# We are expecting this exception
|
||||
self.assertTrue(True)
|
||||
self.assertRaises(exceptions.PolicyNotAuthorized,
|
||||
adminonly_method_with_action, self)
|
||||
|
|
|
@ -41,9 +41,6 @@ function configure_blazar {
|
|||
fi
|
||||
sudo chown $STACK_USER $BLAZAR_CONF_DIR
|
||||
|
||||
BLAZAR_POLICY_FILE=$BLAZAR_CONF_DIR/policy.json
|
||||
cp $BLAZAR_DIR/etc/policy.json $BLAZAR_POLICY_FILE
|
||||
|
||||
touch $BLAZAR_CONF_FILE
|
||||
|
||||
iniset $BLAZAR_CONF_FILE DEFAULT os_auth_version v3
|
||||
|
|
|
@ -39,6 +39,8 @@ extensions = ['sphinx.ext.autodoc',
|
|||
'openstackdocstheme',
|
||||
'oslo_config.sphinxext',
|
||||
'oslo_config.sphinxconfiggen',
|
||||
'oslo_policy.sphinxpolicygen',
|
||||
'oslo_policy.sphinxext',
|
||||
]
|
||||
|
||||
# openstackdocstheme options
|
||||
|
@ -46,11 +48,16 @@ repository_name = 'openstack/blazar'
|
|||
bug_project = 'blazar'
|
||||
bug_tag = ''
|
||||
|
||||
wsme_protocols = ['restjson', 'restxml']
|
||||
|
||||
# oslo_config.sphinxconfiggen options
|
||||
config_generator_config_file = '../../etc/blazar/blazar-config-generator.conf'
|
||||
sample_config_basename = '_static/blazar'
|
||||
|
||||
wsme_protocols = ['restjson', 'restxml']
|
||||
# oslo_policy.sphinxpolicygen options
|
||||
policy_generator_config_file = [
|
||||
('../../etc/blazar/blazar-policy-generator.conf', '_static/blazar'),
|
||||
]
|
||||
|
||||
# The suffix of source filenames.
|
||||
source_suffix = '.rst'
|
||||
|
|
|
@ -2,4 +2,11 @@
|
|||
Policies
|
||||
========
|
||||
|
||||
Please see a sample policy file: :doc:`/configuration/samples/blazar-policy`.
|
||||
The following is an overview of all available policies in Blazar. For a sample
|
||||
configuration file, refer to :doc:`/configuration/samples/blazar-policy`.
|
||||
|
||||
To change policies, please create a policy file in */etc/blazar/* and specify
|
||||
the policy file name at the *oslo_policy/policy_file* option in *blazar.conf*.
|
||||
|
||||
.. show-policy::
|
||||
:config-file: etc/blazar/blazar-policy-generator.conf
|
|
@ -2,6 +2,15 @@
|
|||
Sample Policy File
|
||||
==================
|
||||
|
||||
The following is a sample policy file.
|
||||
The following is a sample blazar policy file for adaptation and use.
|
||||
|
||||
.. literalinclude:: ../../../../etc/policy.json
|
||||
The sample policy can also be viewed in :download:`file form
|
||||
</_static/blazar.policy.yaml.sample>`.
|
||||
|
||||
.. important::
|
||||
|
||||
The sample policy file is auto-generated from blazar when this
|
||||
documentation is built. You must ensure your version of blazar matches
|
||||
the version of this documentation.
|
||||
|
||||
.. literalinclude:: /_static/blazar.policy.yaml.sample
|
|
@ -32,14 +32,6 @@ or
|
|||
|
||||
..
|
||||
|
||||
Next you need to create a Blazar policy file:
|
||||
|
||||
.. sourcecode:: console
|
||||
|
||||
cp /path/to/blazar/etc/policy.json /etc/blazar/
|
||||
|
||||
..
|
||||
|
||||
Next you need to configure Blazar and Nova. First, generate a blazar.conf sample:
|
||||
|
||||
.. sourcecode:: console
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
[DEFAULT]
|
||||
output_file = etc/blazar/policy.yaml.sample
|
||||
namespace = blazar
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"admin": "is_admin:True or role:admin or role:masterofuniverse",
|
||||
|
||||
"admin_or_owner": "rule:admin or project_id:%(project_id)s",
|
||||
|
||||
"default": "!",
|
||||
"admin_api": "rule:admin",
|
||||
|
||||
"blazar:leases:get": "rule:admin_or_owner",
|
||||
"blazar:leases:create": "rule:admin_or_owner",
|
||||
"blazar:leases:delete": "rule:admin_or_owner",
|
||||
"blazar:leases:update": "rule:admin_or_owner",
|
||||
|
||||
"blazar:plugins:get": "@",
|
||||
|
||||
"blazar:oshosts:get": "rule:admin_or_owner",
|
||||
"blazar:oshosts:create": "rule:admin_api",
|
||||
"blazar:oshosts:delete": "rule:admin_api",
|
||||
"blazar:oshosts:update": "rule:admin_api"
|
||||
}
|
|
@ -46,6 +46,9 @@ blazar.api.v2.controllers.extensions =
|
|||
oslo.config.opts =
|
||||
blazar = blazar.opts:list_opts
|
||||
|
||||
oslo.policy.policies =
|
||||
blazar = blazar.policies:list_rules
|
||||
|
||||
wsgi_scripts =
|
||||
blazar-api-wsgi = blazar.api.wsgi_app:init_app
|
||||
|
||||
|
|
5
tox.ini
5
tox.ini
|
@ -68,6 +68,11 @@ basepython = python3
|
|||
commands =
|
||||
rm -rf api-ref/build
|
||||
sphinx-build -WE -b html -d api-ref/build/doctrees api-ref/source api-ref/build/html
|
||||
|
||||
[testenv:genpolicy]
|
||||
commands =
|
||||
oslopolicy-sample-generator --config-file etc/blazar/blazar-policy-generator.conf
|
||||
|
||||
[testenv:lower-constraints]
|
||||
basepython = python3
|
||||
deps =
|
||||
|
|
Loading…
Reference in New Issue