Unverified Commit 9e57e0be authored by Ryan Petrello's avatar Ryan Petrello Committed by GitHub

Merge pull request #484 from ryanpetrello/oauth2

support authentication via oauth2 personal access tokens
parents 88aa6adb d7f83956
This README documentation walks you through the process of generating a local doc site for Tower CLI.
This README documentation walks you through the process of generating a local
doc site for Tower CLI.
Before the actual generating process, make sure you have cloned Tower CLI from
github and checked out the right version tag you want to generate docs from.
Also, we use [graphviz](http://www.graphviz.org/) for some graph generations in
doc. Make sure to install graphviz. For example, on OS X:
Before the actual generating process, make sure you have cloned Tower CLI from github and checked out the right
version tag you want to generate docs from. Also, we use [graphviz](http://www.graphviz.org/) for some graph
generations in doc. Make sure to install graphviz. For example, on OS X:
```
$ brew install graphviz
```
It is always suggested you generate docs in a python virtual environment to prevent any dependency conflicts.
```
$ virtualenv docs
```
And
```
source docs/bin/activate
```
to activate the virtual environment.
In the newly created empty virtual environment, install [sphinx](http://www.sphinx-doc.org/en/stable/), our doc
generating engine.
```
$ sudo pip install sphinx
```
Sphinx walks through an existing python package's source code tree to generate its documentation. so make sure tower
CLI is installed also.
```
$ cd <tower-cli's repo>
$ make install
```
Then, under `docs/` directory, `mkdir build` to create the subdirectory for hosting the local doc site. Also, under
`docs/source`, `mkdir _static` and `mkdir _templates` which are necessary placeholders for doc site compilation.
It is always suggested you generate docs in a python virtual environment to
prevent any dependency conflicts. To simplify this process, we use `tox`:
In Tower CLI, each resource has a lot of fields that need to be grouped and documented as `.rst`-formatted tables.
we provide a script, `docs/source/api_ref/generate_tables.py`, to auto-generate all tables. In order to run the
script, `cd docs/source/api_ref/` and
```
$ python generate_tables.py
$ tox -edocs
```
Now that all documentation source scripts are ready, navigate to `docs/` directory and generate the doc site by
```
$ make html
```
Finally, in web browser, navigate to `file://<wherever tower-cli's repo is>/docs/build/html/index.html` and see
the site for yourself.
Once tox finishes, navigate to `file://<wherever tower-cli's repo
is>/docs/build/html/index.html` in a web browser to view the HTML documentation.
.. _api_ref:
API Reference
=============
......
.. _cli_ref:
CLI Reference
=============
......
.. _installation:
Installation
============
......
......@@ -2,27 +2,47 @@ Quick Start
===========
This chapter walks you through the general process of setting up and using Tower CLI. It starts with CLI usage
and ends with API usage. For details, please see API and CLI references in subsequent chapters.
and ends with API usage. For futher details, please see :ref:`api_ref` and
:ref:`cli_ref`.
It is assumed you have a Tower backend available to talk to and Tower CLI installed. Please see 'Installation'
chapter for instructions on installing Tower CLI.
It is assumed you have a Tower backend available to talk to and Tower CLI installed. Please see the :ref:`installation` chapter for instructions on installing Tower CLI.
First of all, make sure you know the name of the Tower backend, like ``tower.example.com``, as well as the
username/password set of a user in that Tower backend, like ``user/pass``. These are connection information
username/password set of a user in that Tower backend, like ``user/pass``. These are connection details
necessary for Tower CLI to communicate to Tower. With these prerequisites, run
.. code:: bash
$ tower-cli config host tower.example.com
$ tower-cli config username user
$ tower-cli config password pass
$ tower-cli login username
Password:
The first Tower CLI command, ``tower-cli config``. writes the connection information to a configuration file
(``~/.tower-cli.cfg`` in this case), and subsequent commands and API calls will read this file, extract connection
information and talk to Tower as the specified user. See details of Tower CLI configuration in API reference and
The first Tower CLI command, ``tower-cli config``, writes the connection information to a configuration file
(``~/.tower-cli.cfg``, by default), and subsequent commands and API calls will read this file, extract connection
information and interact with Tower. See details of Tower CLI configuration in :ref:`api_ref` and
``tower-cli config --help``.
Then, use Tower CLI to actually control your Tower backend. The CRUD operations against almost every Tower resource
The second command, ``tower-cli login``, will prompt you for your password and
will acquire an OAuth2 token (which will also be saved to a configuration
file) with write scope. You can also request read scope for read-only access:
.. code:: bash
$ tower-cli login username --scope read
Password:
.. note::
Older versions of Tower (prior to 3.3) do not support OAuth2 token
authentication, and instead utilize per-request basic HTTP authentication:
.. code:: bash
$ tower-cli config host tower.example.com
$ tower-cli config username user
$ tower-cli config username pass
Next, use Tower CLI to actually control your Tower backend. The CRUD operations against almost every Tower resource
can be done using Tower CLI. Suppose we want to see the available job templates to choose for running:
.. code:: bash
......
......@@ -14,6 +14,7 @@
# limitations under the License.
import json
import warnings
from datetime import datetime as dt, timedelta
import requests
......@@ -43,14 +44,14 @@ class ClientTests(unittest.TestCase):
URL prefix given a host with no specified protocol.
"""
with settings.runtime_values(host='33.33.33.33'):
self.assertEqual(client.prefix, 'https://33.33.33.33/api/%s/' % CUR_API_VERSION)
self.assertEqual(client.get_prefix(), 'https://33.33.33.33/api/%s/' % CUR_API_VERSION)
def test_prefix_explicit_protocol(self):
"""Establish that the prefix property returns the appropriate
URL prefix and don't clobber over an explicit protocol.
"""
with settings.runtime_values(host='bogus://33.33.33.33/'):
self.assertEqual(client.prefix, 'bogus://33.33.33.33/api/%s/' % CUR_API_VERSION)
self.assertEqual(client.get_prefix(), 'bogus://33.33.33.33/api/%s/' % CUR_API_VERSION)
def test_request_ok(self):
"""Establish that a request that returns a valid JSON response
......@@ -191,7 +192,7 @@ class ClientTests(unittest.TestCase):
with settings.runtime_values(
host='http://33.33.33.33', verify_ssl=True):
with self.assertRaises(exc.TowerCLIError):
client.prefix
client.get_prefix()
def test_failed_suggestion_protocol(self):
"""Establish that if connection fails and protocol not given,
......@@ -248,7 +249,9 @@ class TowerAuthTokenTests(unittest.TestCase):
t.register('/authtoken/', json.dumps({}), status_code=200, method='OPTIONS')
t.register('/authtoken/', json.dumps({'token': 'barfoo', 'expires': expires}), status_code=200,
method='POST')
self.auth(self.req)
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
self.auth(self.req)
self.assertEqual(self.req.headers['Authorization'], 'Token barfoo')
def test_reading_invalid_token_from_server(self):
......@@ -269,5 +272,16 @@ class TowerAuthTokenTests(unittest.TestCase):
with settings.runtime_values(use_token=True):
t.register('/authtoken/', json.dumps({}), status_code=404, method='OPTIONS')
auth = BasicTowerAuth('alice', 'pass', client)
auth(self.req)
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
auth(self.req)
assert self.req.headers == {'Authorization': 'Basic YWxpY2U6cGFzcw=='}
def test_oauth_bearer_token(self):
token = 'Azly3WBiYWeGKfImK25ftpJR1nvn6JABC123'
with settings.runtime_values(oauth_token=token):
auth = BasicTowerAuth(None, None, client)
auth(self.req)
assert self.req.headers == {
'Authorization': 'Bearer {}'.format(token)
}
......@@ -13,17 +13,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import os.path
import stat
import warnings
import click
from click.testing import CliRunner
from fauxquests.response import Resp
from fauxquests.utils import URL
import requests
import tower_cli
from tower_cli.api import client
from tower_cli.cli.misc import config, version, _echo_setting
from tower_cli.api import client, Client
from tower_cli.cli.misc import config, version, login, _echo_setting
from tower_cli.conf import settings
from tower_cli.constants import CUR_API_VERSION
......@@ -269,6 +272,107 @@ class ConfigTests(unittest.TestCase):
'command cowardly declines to create it.')
class LoginTests(unittest.TestCase):
"""Establish that the `tower-cli login` command works in the way
that we expect.
"""
def setUp(self):
self.runner = CliRunner()
def test_no_arguments(self):
"""
Establish that if `tower-cli login` is called with no arguments, it
complains
"""
# Invoke the command.
result = self.runner.invoke(login)
# Ensure that we got a non-zero exit status
self.assertNotEqual(result.exit_code, 0)
self.assertIn('Missing argument "username"', result.output)
def test_oauth_unsupported(self):
"""Establish that if `tower-cli login` is used on a Tower that
doesn't support OAuth2, it shows a meaningful error
"""
with client.test_mode as t:
# You have to modify this internal private registry to
# register a URL endpoint that _doesn't_ have the version
# prefix
prefix = Client().get_prefix(include_version=False)
t._registry[URL(prefix + 'o/', method='HEAD')] = Resp(
''.encode('utf-8'), 404, {}
)
result = self.runner.invoke(
login, ['bob', '--password', 'secret']
)
# Ensure that we got a non-zero exit status
self.assertNotEqual(result.exit_code, 0)
self.assertIn(
'Error: This version of Tower does not support OAuth2.0',
result.output
)
def test_personal_access_token(self):
"""Establish that if `tower-cli login` is called with a username and
password, we obtain and write an oauth token to the config file
"""
# Invoke the command.
mock_open = mock.mock_open()
with mock.patch('tower_cli.cli.misc.open', mock_open,
create=True):
with mock.patch.object(os, 'chmod'):
with client.test_mode as t:
# You have to modify this internal private registry to
# register a URL endpoint that _doesn't_ have the version
# prefix
prefix = Client().get_prefix(include_version=False)
t._registry[URL(prefix + 'o/', method='HEAD')] = Resp(
''.encode('utf-8'), 200, {}
)
t.register('/users/bob/personal_tokens/', json.dumps({
'token': 'abc123'
}), status_code=201, method='POST')
result = self.runner.invoke(
login, ['bob', '--password', 'secret', '--scope', 'read']
)
# Ensure that we got a zero exit status
self.assertEqual(result.exit_code, 0)
assert json.loads(t.requests[-1].body)['scope'] == 'read'
# Ensure that the output seems to be correct.
self.assertIn(mock.call(os.path.expanduser('~/.tower_cli.cfg'), 'w'),
mock_open.mock_calls)
self.assertIn(mock.call().write('oauth_token = abc123\n'),
mock_open.mock_calls)
def test_personal_access_invalid(self):
"""Establish that if `tower-cli login` is called with an invalid
username and password, it shows a meaningful error
"""
with client.test_mode as t:
# You have to modify this internal private registry to
# register a URL endpoint that _doesn't_ have the version
# prefix
prefix = Client().get_prefix(include_version=False)
t._registry[URL(prefix + 'o/', method='HEAD')] = Resp(
''.encode('utf-8'), 200, {}
)
t.register('/users/bob/personal_tokens/', json.dumps({}),
status_code=401, method='POST')
result = self.runner.invoke(
login, ['bob', '--password', 'secret']
)
# Ensure that we got a non-zero exit status
self.assertNotEqual(result.exit_code, 0)
self.assertIn(
'Error: Invalid Tower authentication credentials', result.output
)
class SupportTests(unittest.TestCase):
"""Establish that support functions in this module work in the way
that we expect.
......
......@@ -30,7 +30,7 @@ from requests.auth import AuthBase, HTTPBasicAuth
from tower_cli import exceptions as exc
from tower_cli.conf import settings
from tower_cli.utils import data_structures, debug, secho
from tower_cli.utils import data_structures, debug, secho, supports_oauth
from tower_cli.constants import CUR_API_VERSION
......@@ -47,7 +47,7 @@ class BasicTowerAuth(AuthBase):
def _acquire_token(self):
return self.cli_client._make_request(
'POST', self.cli_client.prefix + 'authtoken/', [],
'POST', self.cli_client.get_prefix() + 'authtoken/', [],
{'data': json.dumps({'username': self.username, 'password': self.password}),
'headers': {'Content-Type': 'application/json'}}
).json()
......@@ -78,9 +78,19 @@ class BasicTowerAuth(AuthBase):
return 'Token ' + token_json['token']
def __call__(self, r):
if 'Authorization' in r.headers:
return r
if settings.oauth_token:
if supports_oauth:
r.headers['Authorization'] = 'Bearer {}'.format(settings.oauth_token)
return r
else:
warnings.warn(
'This version of Tower does not support OAuth2.0'
)
if self.use_legacy_token:
resp = self.cli_client._make_request(
'OPTIONS', self.cli_client.prefix + 'authtoken/', [], {}
'OPTIONS', self.cli_client.get_prefix() + 'authtoken/', [], {}
)
if resp.ok:
r.headers['Authorization'] = self._get_auth_token()
......@@ -156,8 +166,7 @@ class Client(Session):
'Right now it is: "%s".' % settings.host
)
@property
def prefix(self):
def get_prefix(self, include_version=True):
"""Return the appropriate URL prefix to prepend to requests,
based on the host provided in settings.
"""
......@@ -169,7 +178,10 @@ class Client(Session):
'Can not verify ssl with non-https protocol. Change the '
'verify_ssl configuration setting to continue.'
)
return '%s/api/%s/' % (host.rstrip('/'), CUR_API_VERSION)
prefix = os.path.sep.join([host.rstrip('/'), 'api', ''])
if include_version:
prefix = os.path.sep.join([prefix.rstrip('/'), CUR_API_VERSION, ''])
return prefix
@functools.wraps(Session.request)
def request(self, method, url, *args, **kwargs):
......@@ -177,7 +189,8 @@ class Client(Session):
response.
"""
# Piece together the full URL.
url = '%s%s' % (self.prefix, url.lstrip('/'))
use_version = not url.startswith('/o/')
url = '%s%s' % (self.get_prefix(use_version), url.lstrip('/'))
# Ansible Tower expects authenticated requests; add the authentication
# from settings if it's provided.
......@@ -223,12 +236,12 @@ class Client(Session):
# Sanity check: Did we fail to authenticate properly?
# If so, fail out now; this is always a failure.
if r.status_code == 401:
raise exc.AuthError('Invalid Tower authentication credentials.')
raise exc.AuthError('Invalid Tower authentication credentials (HTTP 401).')
# Sanity check: Did we get a forbidden response, which means that
# the user isn't allowed to do this? Report that.
if r.status_code == 403:
raise exc.Forbidden("You don't have permission to do that.")
raise exc.Forbidden("You don't have permission to do that (HTTP 403).")
# Sanity check: Did we get a 404 response?
# Requests with primary keys will return a 404 if there is no response,
......@@ -286,7 +299,7 @@ class Client(Session):
verbose=False, format='json'):
adapters = copy.copy(self.adapters)
faux_adapter = FauxAdapter(
url_pattern=self.prefix.rstrip('/') + '%s',
url_pattern=self.get_prefix().rstrip('/') + '%s',
)
try:
......
......@@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import collections
import json
import os
import stat
import warnings
......@@ -19,15 +21,16 @@ import warnings
import click
import six
from requests.auth import HTTPBasicAuth
from requests.exceptions import RequestException
from tower_cli import __version__, exceptions as exc
from tower_cli.api import client
from tower_cli.conf import with_global_options, Parser, settings
from tower_cli.utils import secho
from tower_cli.conf import with_global_options, Parser, settings, _apply_runtime_setting
from tower_cli.utils import secho, supports_oauth
from tower_cli.constants import CUR_API_VERSION
__all__ = ['version', 'config']
__all__ = ['version', 'config', 'login', 'logout']
@click.command()
......@@ -204,3 +207,54 @@ def config(key=None, value=None, scope='user', global_=False, unset=False):
UserWarning
)
click.echo('Configuration updated successfully.')
@click.command()
@click.argument('username', required=True)
@click.option('--password', required=True, prompt=True, hide_input=True)
@click.option('--scope', required=False, default='write',
type=click.Choice(['read', 'write']))
@click.option('-v', '--verbose', default=None,
help='Show information about requests being made.', is_flag=True,
required=False, callback=_apply_runtime_setting, is_eager=True)
def login(username, password, scope, verbose):
"""
Retrieves and stores an OAuth2 personal auth token.
"""
if not supports_oauth():
raise exc.TowerCLIError(
'This version of Tower does not support OAuth2.0'
)
# Explicitly set a basic auth header for PAT acquisition (so that we don't
# try to auth w/ an existing user+pass or oauth2 token in a config file)
req = collections.namedtuple('req', 'headers')({})
HTTPBasicAuth(username, password)(req)
r = client.post(
'/users/{}/personal_tokens/'.format(username),
data={"description": "Tower CLI", "application": None, "scope": scope},
headers=req.headers
)
if r.ok:
result = r.json()
result.pop('summary_fields', None)
result.pop('related', None)
token = result.pop('token', None)
if settings.verbose:
# only print the actual token if -v
result['token'] = token
secho(json.dumps(result, indent=1), fg='blue', bold=True)
config.main(['oauth_token', token, '--scope=user'])
@click.command()
def logout():
"""
Removes an OAuth2 personal auth token from config.
"""
if not supports_oauth():
raise exc.TowerCLIError(
'This version of Tower does not support OAuth2.0'
)
config.main(['oauth_token', '--unset', '--scope=user'])
......@@ -38,7 +38,7 @@ CONFIG_FILENAME = '.tower_cli.cfg'
CONFIG_OPTIONS = frozenset((
'host', 'username', 'password', 'verify_ssl', 'format',
'color', 'verbose', 'description_on', 'certificate',
'use_token'
'use_token', 'oauth_token'
))
......@@ -344,8 +344,9 @@ def _apply_runtime_setting(ctx, param, value):
SETTINGS_PARMS = set([
'tower_host', 'tower_password', 'format', 'tower_username', 'verbose',
'description_on', 'insecure', 'certificate', 'use_token'
'tower_host', 'tower_oauth_token', 'tower_password', 'format',
'tower_username', 'verbose', 'description_on', 'insecure', 'certificate',
'use_token'
])
......@@ -378,6 +379,14 @@ def with_global_options(method):
required=False, callback=_apply_runtime_setting,
is_eager=True
)(method)
method = click.option(
'-t', '--tower-oauth-token',
help='OAuth2 token to use to authenticate to Ansible Tower. '
'This will take precedence over a token provided to '
'`tower config`, if any.',
required=False, callback=_apply_runtime_setting,
is_eager=True
)(method)
method = click.option(
'-u', '--tower-username',
help='Username to use to authenticate to Ansible Tower. '
......
......@@ -34,3 +34,13 @@ def secho(message, **kwargs):
# Okay, now call click.secho normally.
return click.secho(message, **kwargs)
def supports_oauth():
# Import here to avoid a circular import
from tower_cli.api import client
try:
resp = client.head('/o/')
except exceptions.NotFound:
return False
return resp.ok
......@@ -38,5 +38,12 @@ deps =
deps = flake8
commands = flake8 {toxinidir}
[testenv:docs]
deps = {[testenv]deps}
sphinx
changedir = docs
commands = python source/api_ref/generate_tables.py
make html
[flake8]
max-line-length=120
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment