diff --git a/.gitignore b/.gitignore index 20d1a76..8941481 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +generated-policy.yml +data_key artifacts pytest.xml htmlcov diff --git a/Dockerfile b/Dockerfile index 8a33001..9826ccf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /app COPY requirements* /app/ RUN pip install -r requirements.txt -r requirements_dev.txt -COPY . /app +ENV PYTHONPATH /app +VOLUME /app VOLUME /artifacts diff --git a/README.md b/README.md index 87c078e..9065cb5 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ of all classes and methods. from conjur.config import config # Set the conjur appliance url. This can also be provided -# by the CONJUR_APPLIANCE_URL environment variable. -config.appliance_url = 'https://conjur.example.com/api' +# by the POSSUM_URL environment variable. +config.url = 'https://possum.example' # Set the (PEM) certificate file. This is also configurable with the # CONJUR_CERT_FILE environment variable. diff --git a/conjur/__init__.py b/conjur/__init__.py index 750cd8a..9362ebf 100644 --- a/conjur/__init__.py +++ b/conjur/__init__.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2014 Conjur Inc +# Copyright (C) 2014-2016 Conjur Inc # # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in @@ -17,17 +17,15 @@ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +import base64 import os +import re +import requests from config import Config from api import API -from group import Group -from user import User -from host import Host -from layer import Layer from resource import Resource from role import Role -from variable import Variable from exceptions import ConjurException from config import config @@ -57,32 +55,6 @@ def configure(**kwargs): config.update(**kwargs) return config - -def new_from_netrc(netrc_file=None, configuration=None): - """ - Create a `conjur.API` instance using an identity loaded from netrc. This method - uses the identity stored for the host `config.authn_url`. - - `netrc_file` is an alternative path to the netrc formatted file. Defaults - to ~/.netrc on unixy systems. - - `configuration` is a `conjur.Config` instance used to determine the host - in the netrc file, and also passed to the `conjur.new_from_key` method to - create the API instance using the identity. - """ - import netrc - - configuration = _config(configuration) - auth = netrc.netrc(netrc_file).authenticators(configuration.authn_url) - if auth is None: - raise ValueError("No authenticators found for authn_url '%s' in %s" % ( - configuration.authn_url, - (netrc_file or '~/.netrc') - )) - login, _, api_key = auth - return new_from_key(login, api_key, configuration) - - def new_from_key(login, api_key, configuration=None): """ Create a `conjur.API` instance that will authenticate on demand as the identity given @@ -90,7 +62,7 @@ def new_from_key(login, api_key, configuration=None): `login` is the identity of the Conjur user or host to authenticate as. - `api_key` is the api key *or* password to use when authenticating. + `api_key` is the api key to use when authenticating. `configuration` is a `conjur.Config` instance for the api. If not given the global `Config` instance (`conjur.config`) will be used. @@ -98,6 +70,27 @@ def new_from_key(login, api_key, configuration=None): return API(credentials=(login, api_key), config=_config(configuration)) +def new_from_password(login, password, configuration=None): + """ + Create a `conjur.API` instance that will authenticate immediately (to + exchange the password for the API key) as the identity given by `login` + and `api_key`. + + `login` is the identity of the Conjur user or host to authenticate as. + + `password` is the password to use when authenticating. + + `configuration` is a `conjur.Config` instance for the api. If not given the global + `Config` instance (`conjur.config`) will be used. Note it needs to be + set up correctly before using this function. + """ + configuration = _config(configuration) + url = "%s/authn/%s/login" % (configuration.url, configuration.account) + response = requests.get(url, auth=(login, password), verify=configuration.verify) + if response.status_code != 200: + raise ConjurException("Authentication error: {} {}".format(response.status_code, response.reason)) + api_key = response.text + return new_from_key(login, api_key, configuration) def new_from_token(token, configuration=None): """ @@ -114,7 +107,22 @@ def new_from_token(token, configuration=None): """ return API(token=token, config=_config(configuration)) +def new_from_header(authorization_header, configuration=None): + """ + Create a `conjur.API` instance based on an Authorization header. + + This is mostly useful for proxies, authenticators and wrappers which + forward Authorization header supplied by the client. + + `authorization_header` is the Authorization header contents, + eg. `Token token=""`. + + `configuration` is a conjur.Config instance for the api. If not given, the global Config + instance (`conjur.config`) will be used. + """ + return API(header=authorization_header, config=_config(configuration)) + __all__ = ( 'config', 'Config', 'Group', 'API', 'User', 'Host', 'Layer', 'Resource', 'Role', 'Variable', 'new_from_key', 'new_from_netrc', 'new_from_token', 'configure', 'ConjurException' -) \ No newline at end of file +) diff --git a/conjur/api.py b/conjur/api.py index 2d479fe..a9024ec 100644 --- a/conjur/api.py +++ b/conjur/api.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2014 Conjur Inc +# Copyright (C) 2014-2016 Conjur Inc # # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in @@ -22,23 +22,18 @@ import requests -from conjur.variable import Variable -from conjur.user import User from conjur.role import Role -from conjur.group import Group -from conjur.layer import Layer -from conjur.host import Host from conjur.resource import Resource from conjur.util import urlescape from conjur.exceptions import ConjurException class API(object): - def __init__(self, credentials=None, token=None, config=None): + def __init__(self, credentials=None, token=None, header=None, config=None): """ Creates an API instance configured with the given credentials or token and config. - Generally you should use `conjur.new_from_key`, `conjur.new_from_netrc`, + Generally you should use `conjur.new_from_key`, `conjur.new_from_password`, or `conjur.new_from_token` to get an API instance instead of calling this constructor directly. @@ -54,6 +49,9 @@ def __init__(self, credentials=None, token=None, config=None): elif token: self.token = token self.login = self.api_key = None + elif header: + self.header = header + self.login = self.api_key = None else: raise TypeError("must be given a credentials or token argument") if config: @@ -84,7 +82,8 @@ def authenticate(self, cached=True): raise ConjurException( "API created without credentials can't authenticate") - url = "%s/users/%s/authenticate" % (self.config.authn_url, + url = "%s/authn/%s/%s/authenticate" % (self.config.url, + self.config.account, urlescape(self.login)) self.token = self._request('post', url, self.api_key).text @@ -97,9 +96,12 @@ def auth_header(self): Returns a string suitable for use as an `Authorization` header value. """ - token = self.authenticate() - enc = base64.b64encode(token) - return 'Token token="%s"' % enc + try: + return self.header + except AttributeError: + token = self.authenticate() + enc = base64.b64encode(token) + return 'Token token="%s"' % enc def request(self, method, url, **kwargs): """ @@ -132,7 +134,11 @@ def _request(self, method, url, *args, **kwargs): response = getattr(requests, method.lower())(url, *args, **kwargs) if check_errors and response.status_code >= 300: - raise ConjurException("Request failed: %d" % response.status_code) + try: + error = response.json()['error'] + except: + raise ConjurException("Request failed: %d" % response.status_code) + raise ConjurException("%s : %s" % ( error['code'], error['message'] )) return response @@ -208,7 +214,13 @@ def role(self, kind, identifier): `identifier` should be the *unqualified* Conjur id. For example, to get the role for a user named bub, you would call `api.role('user', 'bub')`. """ - return Role(self, kind, identifier) + return Role(self, kind=kind, id=identifier) + + def role_qualified(self, qualified_identifier): + """ + Return a `conjur.Role` corresponding to the given qualified identifier. + """ + return Role(self, id=qualified_identifier) def resource(self, kind, identifier): """ @@ -223,174 +235,27 @@ def resource(self, kind, identifier): get the resource for a user variable named db_password, you would call `api.resource('variable', 'db_password')`. """ - return Resource(self, kind, identifier) - - def group(self, id): - """ - Return a `conjur.Group` object with the given id. - - This method neither creates nor checks for the groups's existence. - - `id` is the *unqualified* id of the group, and does not include the account or kind. - """ - return Group(self, id) - - def create_group(self, id): - """ - Creates a Conjur Group and returns a `conjur.Group` object representing it. - - `id` is the identifier of the group to create. - """ - - self.post('{0}/groups'.format(self.config.core_url), data={'id': id}) - return Group(self, id) - - def variable(self, id): - """ - Return a `conjur.Variable` object with the given `id`. - - This method neither creates nor checks for the variable's existence. - """ - return Variable(self, id) - - def create_variable(self, id=None, mime_type='text/plain', kind='secret', - value=None): - """ - Creates a Conjur variable. - - Returns a `conjur.Variable` object. - - `id` is an identifier for the new variable. If not given, a unique id will - be generated. - - `mime_type` is a string like `text/plain` indicating the content type stored by the - variable. This determines the Content-Type header of responses returning the variable's value. - - `kind` is a string indicating a user defined role for the variable. - Ignored by Conjur, but useful for making a variable's - purpose. - - `value` is a string assigning an initial value for the variable. - """ - data = {'mime_type': mime_type, 'kind': kind} - if id is not None: - data['id'] = id - if value is not None: - data['value'] = value - - attrs = self.post("%s/variables" % self.config.core_url, data=data).json() - id = id or attrs['id'] - return Variable(self, id, attrs) - - def layer(self, layer_id): - """ - Return a `conjur.Layer` object with the given `layer_id`. - - This method neither creates nor checks for the layer's existence. - """ - return Layer(self, layer_id) - - def host(self, host_id): - """ - Return a `conjur.Host` object with the given `host_id`. + return Resource(self, kind=kind, id=identifier) - This method neither creates nor checks for the host's existence. + def resource_qualified(self, qualified_identifier): """ - return Host(self, host_id) - - def create_host(self, host_id): - """ - Creates a Conjur Host and returns a `conjur.Host` object that represents it. - - `host_id` is the id of the Host to be created. The `conjur.Host` object returned by - this method will have an `api_key` attribute, but when the Host is fetched in the future this attribute - is not available. - """ - attrs = self.post("{0}/hosts".format(self.config.core_url), - data={'id': host_id}).json() - return Host(self, host_id, attrs) - - def user(self, login): - """ - Returns an object representing a Conjur user with the given login. - - The user is *not* created by this method, and may in fact not exist. - """ - return User(self, login) - - def create_user(self, login, password=None): + Return a `conjur.Resource` corresponding to the given qualified identifier. """ - Create a Conjur user with the given `login` and password, and returns a `conjur.User` object - representing it. + return Resource(self, id=qualified_identifier) - If `password` is not given, the user will only be able to authenticate using the generated api_key - attribute of the returned User instance. Note that this `api_key` will not be available when the User - is fetched in the future. + def resources(self, kind=None): """ - data = {'login': login} - if password is not None: - data['password'] = password - url = "{0}/users".format(self.config.core_url) - return User(self, login, self.post(url, data=data).json()) - - def _public_key_url(self, *args): - return '/'.join([self.config.pubkeys_url] + - [urlescape(arg) for arg in args]) - - def add_public_key(self, username, key): - """ - Upload an openssh formatted public key to be made available for the user - given by `username`. - - The key should be formatted like `ssh-rsa bob@example.com`. - """ - self.post(self._public_key_url(username), data=key) - - def remove_public_key(self, username, keyname): - """ - Remove a specific public key for the user identified by `username`. - The `keyname` argument refers to the name field in the openssh formatted key - to be deleted. - - For example, if they key contents are `ssh-rsa bob@example.com`, - the `keyname` should be `bob@example.com` + Return a list of all `conjur.Resources` from the account, optionally filtered by kind. """ - self.delete(self._public_key_url(username, keyname)) + resources = self.get(self._resources_url(kind=kind)).json() + return [self.resource_qualified(r["id"]) for r in resources] - def remove_public_keys(self, username): - """ - Remove all public keys for the user represented by `username`. - """ - for keyname in self.public_key_names(username): - self.remove_public_key(username, keyname) - - def public_keys(self, username): - """ - Returns all keys for the user given by `username`, as a newline delimited string. - - The odd format is chosen to support the Conjur SSH login implementation. - """ - return self.get(self._public_key_url(username)).text - - def public_key(self, username, keyname): - """ - Return the contents of a specific public key given by `keyname`, - for the user given by `username` as a string. - - The name of the key is based on the name entry of the openssh formatted key that was uploaded. - - For example, if they key contents are `ssh-rsa bob@example.com`, - the `keyname` should be `bob@example.com` - """ - return self.get(self._public_key_url(username, keyname)).text - - def public_key_names(self, username): - """ - Return the names of public keys for the user given by `username`. - - The names of the keys are based on the name entry of the openssh formatted key that was uploaded. - - For example, if they key contents are `ssh-rsa bob@example.com`, - the `keyname` should be `bob@example.com` - """ - return [k.split(' ')[-1] for k in self.public_keys(username).split('\n')] + def _resources_url(self, kind=None): + pieces = [ + self.config.url, + 'resources', + self.config.account, + ] + if kind: + pieces += [kind] + return '/'.join(pieces) diff --git a/conjur/cli.py b/conjur/cli.py new file mode 100644 index 0000000..eb2c053 --- /dev/null +++ b/conjur/cli.py @@ -0,0 +1,249 @@ +from __future__ import print_function +from netrc import netrc +from urlparse import urlparse +import getpass +import argparse +import sys +import os +import conjur +import inflection +import json +from tabulate import tabulate + +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + +def netrc_path(): + try: + return os.environ['CONJURRC'] + except KeyError: + return os.path.expanduser('~/.netrc') + +def touch_netrc(): + path = netrc_path() + with open(path, 'a'): + os.utime(path, None) + os.chmod(path, 0o600) + +def netrc_str(netrc): + """Dump the class data in the format of a .netrc file.""" + rep = "" + for host in netrc.hosts.keys(): + attrs = netrc.hosts[host] + rep = rep + "machine "+ host + "\n\tlogin " + str(attrs[0]) + "\n" + if attrs[1]: + rep = rep + "account " + str(attrs[1]) + rep = rep + "\tpassword " + str(attrs[2]) + "\n" + for macro in netrc.macros.keys(): + rep = rep + "macdef " + macro + "\n" + for line in netrc.macros[macro]: + rep = rep + line + rep = rep + "\n" + return rep + +def credentials_from_netrc(): + try: + creds = netrc(netrc_path()) + except IOError: + raise Exception("Not logged in. File %s does not exist" % netrc_path()) + + result = creds.authenticators(conjur.config.url) + try: + return ( result[0], result[2] ) + except TypeError: + raise Exception("Conjur URL %s is not in %s, therefore you're not logged in" % ( conjur.config.url, netrc_path() )) + +def credentials(): + try: + return ( os.environ['CONJUR_AUTHN_LOGIN'], os.environ['CONJUR_AUTHN_API_KEY'] ) + except KeyError: + return credentials_from_netrc() + +def connect(): + return conjur.new_from_key(*credentials()) + +def current_roleid(): + # Ensure the login is valid + api = connect() + api.authenticate() + username = api.login + account = conjur.config.account + if username.find('/') != -1: + kind, _, id = username.partition('/') + else: + kind, id = ( 'user', username) + return "%s:%s:%s" % ( account, kind, id ) + +def find_object(kind, id): + api = connect() + if len(id.split(':')) > 1: + return api.resource_qualified(id) + else: + return api.resource(kind, id) + +def find_variable(id): + return find_object('variable', id) + +def find_policy(id): + return find_object('policy', id) + +def interpret_login(role): + tokens = role.split(':', 1) + if len(tokens) == 2: + return tokens + else: + return ( 'user', tokens[0] ) + +def login_handler(args): + role = args.role + if not role: + eprint("Enter your username to log into Possum: ", end="") + role = raw_input() + kind, identifier = interpret_login(role) + username = '/'.join((kind, identifier)) + + if args.rotate: + api = connect() + role = conjur.Role.from_roleid(api, ":".join((kind, identifier))) + api_key = role.rotate_api_key() + else: + password = getpass.getpass("Enter password for %s %s (it will not be echoed): " % ( kind, identifier )) + api_key = conjur.new_from_password(username, password).api_key + + save_api_key(username, api_key) + print("Logged in") + +def save_api_key(username, api_key): + touch_netrc() + logins = netrc(netrc_path()) + logins.hosts[conjur.config.url] = ( username, None, api_key ) + with open(netrc_path(), 'w') as f: + f.write(netrc_str(logins)) + +def authenticate_handler(args): + import base64 + token = connect().authenticate() + if args.H: + token = base64.b64encode(token) + print(token) + +def whoami_handler(args): + print(current_roleid()) + +def rotate_api_key_handler(args): + api = connect() + role = conjur.Role.from_roleid(api, args.role or current_roleid()) + api_key = role.rotate_api_key() + if not args.role: + save_api_key(api.login, api_key) + print(api_key) + +def list_handler(args): + def flatten_record(record): + result = [ record['id'], record['owner'] ] + if 'policy' in record.keys(): + result.append(record['policy']) + return result + + api = connect() + resources = [ flatten_record(resource) for resource in api.get(api._resources_url(kind=args.kind)).json() ] + print(tabulate(resources, headers=['Id', 'Owner', 'Policy'])) + +def show_handler(args): + api = connect() + resource = api.resource_qualified(args.id) + resource = resource.api.get(resource.url()).json() + keys = resource.keys() + keys.sort() + def format_value(value): + if isinstance(value, basestring): + return value + else: + return json.dumps(value) + + data = [ ( inflection.camelize(key), format_value(resource[key]) ) for key in keys ] + print(tabulate(data)) + +def policy_load_handler(args): + if args.policy == '-': + value = sys.stdin.read() + else: + value = args.policy + policy = find_policy(args.id) + url = '/'.join([ + policy.api.config.url, + 'policies', + policy.api.config.account, + 'policy', + conjur.util.urlescape(policy.identifier) + ]) + + response = policy.api.post(url, data=value).json() + created_roles = response['created_roles'] + print("") + print("Loaded policy version %s" % response['version']) + if len(created_roles) > 0: + print("Created %s roles" % len(created_roles)) + print("") + print(tabulate([ ( record['id'], record['api_key'] ) for record in created_roles.values() ], ("Id", "API Key"))) + print("") + +def fetch_handler(args): + value = find_variable(args.id).secret(args.version) + if value: + print(value) + else: + sys.exit(1) + +def store_handler(args): + if args.value == '-': + value = sys.stdin.read() + else: + value = args.value + find_variable(args.id).add_secret(value) + print("Value added") + +parser = argparse.ArgumentParser(description='Possum command-line interface.') +subparsers = parser.add_subparsers() + +login = subparsers.add_parser('login') +login.add_argument('-r', '--role') +login.add_argument('--rotate', action='store_true') +login.set_defaults(func=login_handler) + +whoami = subparsers.add_parser('whoami') +whoami.set_defaults(func=whoami_handler) + +authenticate = subparsers.add_parser('authenticate') +authenticate.add_argument('-H', action='store_true') +authenticate.set_defaults(func=authenticate_handler) + +rotate_api_key = subparsers.add_parser('rotate_api_key') +rotate_api_key.add_argument('-r', '--role') +rotate_api_key.set_defaults(func=rotate_api_key_handler) + +list_ = subparsers.add_parser('list') +list_.add_argument('-k', '--kind', help='Resource kind') +list_.set_defaults(func=list_handler) + +show = subparsers.add_parser('show') +show.add_argument('id') +show.set_defaults(func=show_handler) + +policy_load = subparsers.add_parser('policy:load') +policy_load.add_argument('id') +policy_load.add_argument('policy') +policy_load.set_defaults(func=policy_load_handler) + +store = subparsers.add_parser('store') +store.add_argument('id') +store.add_argument('value') +store.set_defaults(func=store_handler) + +fetch = subparsers.add_parser('fetch') +fetch.add_argument('id') +fetch.add_argument('-V', '--version', help='Variable version') +fetch.set_defaults(func=fetch_handler) + +args = parser.parse_args() +args.func(args) diff --git a/conjur/config.py b/conjur/config.py index 24f4860..b854025 100644 --- a/conjur/config.py +++ b/conjur/config.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2014 Conjur Inc +# Copyright (C) 2014-2016 Conjur Inc # # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in @@ -25,28 +25,14 @@ `conjur.config.config`. This object is used when a `conjur.api.API` instance is created without a config. -Example: - ```python - from conjur.config import config +The most important settings are `url`, `account`, and `cert_file`. - # Set multiple keys using the `update + * `url` points the client to your Possum instance. -The most important settings are `appliance_url`, `account`, and `cert_file`. + * `account` is an organizational name you want to use. This value is required. - * `appliance_url` points the client to your Conjur appliance. Suppose the hostname of - the appliance is `conjur.example.com`. Then the appliance url is `https://conjur.example.com/api`. - Note that the `scheme` **must** be `https`, and the `/api` path is required. - - * `account` is an organizational name chosen when you deploy your conjur appliance. The CLI command - `conjur authn whoami` will show you this value. This value is required. - - * `cert_file` is the path to a `pem` formated certificate used to make a secure connection to the - Conjur appliance. The CLI command `conjur init -h ` is the standard way to install - such a certificate. This option is **required** if you are using a self-signed certificate (which is - the most common use case). - -While endpoints for specific services can also be configured, this is not normally needed -outside of development work. + * `cert_file` is the path to a `pem` formated certificate used to make a secure connection to + Possum. This option is **required** if you are using https with a self-signed certificate. """ import os @@ -67,16 +53,6 @@ def fset(self, value): return property(fget, fset, doc=doc) -def _service_url(name, per_account=True, doc=''): - def fget(self): - return self.service_url(name, per_account) - - def fset(self, value): - self.set(name + '_url', value) - - return property(fget=fget, fset=fset, doc=doc) - - class Config(object): def __init__(self, **kwargs): self._config = {} @@ -94,18 +70,6 @@ def update(self, *dicts, **kwargs): for d in dicts + (kwargs, ): self._config.update(d) - def service_url(self, service, per_account=True): - key = '%s_url' % service - if key in self._config: - return self._config[key] - if self.appliance_url is not None: - url_parts = [self.appliance_url] - if service != "core": - url_parts.append(service) - return "/".join(url_parts) - else: - raise ConfigException('Missing appliance_url') - def get(self, key, default=_DEFAULT): if key in self._config: return self._config[key] @@ -121,22 +85,13 @@ def get(self, key, default=_DEFAULT): def set(self, key, value): self._config[key] = value - authn_url = _service_url('authn', doc='URL for the authn service') - core_url = _service_url('core', doc='URL for the core service') - authz_url = _service_url('authz', - per_account=False, - doc='URL for the authz service') - - pubkeys_url = _service_url('pubkeys', doc='URL for the pubkeys service') - - cert_file = _setting('cert_file', None, "Path to certificate to verify ssl requests \ to appliance") account = _setting('account', 'conjur', 'Conjur account identifier') - appliance_url = _setting('appliance_url', None, 'URL for Conjur appliance') + url = _setting('url', None, 'URL for Possum') @property def verify(self): diff --git a/conjur/group.py b/conjur/group.py deleted file mode 100644 index 27d3d42..0000000 --- a/conjur/group.py +++ /dev/null @@ -1,88 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -class Group(object): - """ - Represents a Conjur [group](https://developer.conjur.net/reference/services/directory/group). - - Generally you won't create instances of this class, but use the `conjur.API.group(id)` method. - - A group is a role that contains other roles, typically users and other groups. A `conjur.Group` - object can list its members with the `members` method, and also manage them with the `add_member` - and `remove_member` methods. - """ - def __init__(self, api, id): - self.api = api - """ - Instance of `conjur.API` used to implement Conjur operations. - """ - - self.id = id - """ - Identifier (unqualified) of the group. - """ - - self.role = api.role('group', id) - """ - Represents the `conjur.Role` associated with this group. - """ - - def members(self): - """ - Return a list of members of this group. Members are returned as `dict`s - with the following keys: - - * `'member'` the fully qualified identifier of the group - * `'role'` the fully qualified identifier of the group (redundant) - * `'grantor'` the role that granted the membership - * `'admin_option'` whether this member can grant membership in the group to other roles. - - Example: print member ids (fully qualified) and whether they are admins of the group. - >>> group = api.group('security_admin') - >>> for member in group.members(): - ... print('{} is a member of security_admin ({} admin option)'.format( - ... member['member'], - ... 'with' if member['admin_option'] else 'without' - ... )) - """ - return self.role.members() - - def add_member(self, member, admin=False): - """ - Add a member to this group. - - `member` is the member we want to add to the group, and should be a qualified Conjur id, - or an object with a `role` attribute or a `roleid` method. Examples of such objects - include `conjur.User`, `conjur.Role`, and `conjur.Group`. - - If `admin` is True, the member will be allowed to add other members to this group. - """ - self.role.grant_to(member, admin) - - def remove_member(self, member): - """ - Remove a member from the group. - - `member` is the member to remove, and should be a qualified Conjur id, - or an object with a `role` attribute or a `roleid` method. Examples of such objects - include `conjur.User`, `conjur.Role`, and `conjur.Group`. - """ - self.role.revoke_from(member) diff --git a/conjur/host.py b/conjur/host.py deleted file mode 100644 index 51e308d..0000000 --- a/conjur/host.py +++ /dev/null @@ -1,85 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from conjur.util import urlescape -from conjur.exceptions import ConjurException - - -class Host(object): - """ - A Conjur `Host` is a role corresponding to a machine or machine identity. - - The `Host` class provides the ability to check for existence and read attributes of the - host. - - Attributes (such as the `ownerid`) are fetched lazily. - - Newly created hosts, as returned by `conjur.API.create_host`, have an `api_key` attribute, - but existing hosts retrieved with `conjur.API.host` or the constructor of this class *do not* - have one. - - Example: - - >>> # Create a host and save it's api key to a file. - >>> host = api.create_host('jenkins') - >>> api_key = host.api_key - >>> with open('/etc/conjur.identity') as f: - ... f.write(api_key) - - Example: - - >>> # See if a host named `jenkins` exists: - >>> if api.host('jenkins').exists(): - ... print("Host 'jenkins' exists") - ... else: - ... print("Host 'jenkins' does not exist") - - """ - def __init__(self, api, id, attrs=None): - self.api = api - self.id = id - self._attrs = attrs - self.role = self.api.role('host', self.id) - - def exists(self): - """ - Return `True` if this host exists. - """ - status = self.api.get(self._url(), check_errors=False).status_code - if status == 200: - return True - if status == 404: - return False - raise ConjurException("Request Failed: {0}".format(status)) - - def _fetch(self): - self._attrs = self.api.get(self._url()).json() - - def _url(self): - return "{0}/hosts/{1}".format(self.api.config.core_url, - urlescape(self.id)) - - def __getattr__(self, item): - if self._attrs is None: - self._fetch() - try: - return self._attrs[item] - except KeyError: - raise AttributeError(item) diff --git a/conjur/layer.py b/conjur/layer.py deleted file mode 100644 index 4e962e2..0000000 --- a/conjur/layer.py +++ /dev/null @@ -1,66 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from conjur.util import urlescape, authzid -from conjur.exceptions import ConjurException - - -class Layer(object): - def __init__(self, api, id, attrs=None): - self.api = api - self.id = id - self._attrs = {} if attrs is None else attrs - - def add_host(self, host): - hostid = authzid(host, 'role', with_account=False) - self.api.post(self._hosts_url(), data={'hostid': hostid}) - - def remove_host(self, host): - hostid = authzid(host, 'role') - self.api.delete(self._host_url(hostid)) - - def exists(self): - resp = self.api.get(self._url(), check_errors=False) - if resp.status_code == 200: - return True - if resp.status_code == 404: - return False - raise ConjurException("Request Failed: {0}".format(resp.status_code)) - - def _url(self): - return "{0}/layers/{1}".format(self.api.config.core_url, - urlescape(self.id)) - - def _hosts_url(self): - return "{0}/hosts".format(self._url()) - - def _host_url(self, host_id): - return "{0}/{1}".format(self._hosts_url(), urlescape(host_id)) - - def _fetch(self): - self._attrs = self.api.get(self._url()).json() - - def __getattr__(self, item): - if self._attrs is None: - self._fetch() - try: - return self._attrs[item] - except KeyError: - raise AttributeError(item) diff --git a/conjur/resource.py b/conjur/resource.py index da612d8..6db47da 100644 --- a/conjur/resource.py +++ b/conjur/resource.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2014 Conjur Inc +# Copyright (C) 2014-2016 Conjur Inc # # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal inre @@ -18,7 +18,7 @@ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from conjur.util import authzid +from conjur.util import authzid, split_id from conjur.role import Role from conjur.exceptions import ConjurException @@ -28,60 +28,33 @@ class Resource(object): A `Resource` represents an object on which `Role`s can be permitted to perform certain actions. - Generally you will not construct these directly, but call the `conjur.API.role` method + Generally you will not construct these directly, but call the `conjur.API.resource` method to do so. + + Resources can be used to represent secrets; conventionally resources with + `variable` kind are used for this purpose, but this is not enforced. + + In particular, resources of (kind, id) in the form of ('public_key', + 'username/key_id') are customarily used to represent public keys of a + given user, with the keys themselves attached as secrets. + (Note public keys aren't secrets per se, but are stored as such for consistency.) """ - def __init__(self, api, kind, identifier): + def __init__(self, api, account=None, kind=None, id=None): self.api = api - self.kind = kind - self.identifier = identifier + [self.account, self.kind, self.identifier] = split_id(id) + self.account = self.account or account or api.config.account + self.kind = self.kind or kind + assert self.account and (not account or self.account == account) + assert self.kind and (not kind or self.kind == kind) + assert self.identifier @property def resourceid(self): """ The fully qualified resource id as a string, like `'the-account:variable:db-password`. """ - return ":".join([self.api.config.account, self.kind, self.identifier]) - - def permit(self, role, privilege, grant_option=False): - """ - Permit `role` to perform `privilege` on this resource. - - `role` is a qualified conjur identifier (e.g. `'user:alice`') or an object - with a `role` attribute or `roleid` method, such as a `conjur.User` or - `conjur.Group`. - - If `grant_option` is True, the role will be able to grant this - permission to other resources. - - You must own the resource or have the permission with `grant_option` - to call this method. - """ - data = {} - params = { - 'permit': 'true', - 'privilege': privilege, - 'role': authzid(role, 'role') - } - if grant_option: - data['grant_option'] = 'true' - - self.api.post(self.url(), data=data, params=params) - - def deny(self, role, privilege): - """ - Deny `role` permission to perform `privilege` on this resource. - - You must own the resource or have the permission with `grant_option` - on it to call this method. - """ - params = { - 'permit': 'true', - 'privilege': privilege, - 'role': authzid(role) - } + return ":".join([self.account, self.kind, self.identifier]) - self.api.post(self.url(), params=params) def permitted(self, privilege, role=None): """ @@ -134,9 +107,60 @@ def url(self): Internal method to return a url for this object as a string. """ return "/".join([ - self.api.config.authz_url, - self.api.config.account, + self.api.config.url, 'resources', + self.account, + self.kind, + self.identifier + ]) + + def secret(self, version=None): + """ + Retrieve the secret attached to this resource. + + `version` is a *one based* index of the version to be retrieved. + + If no such version exists, None is returned. + + Returns the value of the secret as a string. + """ + url = self.secret_url() + if version is not None: + url = "%s?version=%s" % (url, version) + res = self.api.get(url, check_errors = False) + if res.status_code < 300: + return res.text + elif res.status_code == 404: + return None + else: + raise ConjurException("Request failed: %d" % res.status_code) + + def add_secret(self, value): + """ + Stores a new version of the secret in this resource. + + `value` is a string giving the new value to store. + """ + self._attrs = None + data = value + self.api.post(self.secret_url(), data=data) + + def secret_url(self): + """ + Internal method to return a url for the secrets of this object as a string. + """ + return "/".join([ + self.api.config.url, + 'secrets', + self.account, self.kind, self.identifier ]) + + def role(self): + """ + Return the corresponding role (ie. with the same id). + Note not every resource will have one, so the + returned object may refer to a nonexistent role. + """ + return self.api.role_qualified(self.resourceid) diff --git a/conjur/role.py b/conjur/role.py index afec968..3953a8a 100644 --- a/conjur/role.py +++ b/conjur/role.py @@ -18,7 +18,7 @@ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from conjur.util import urlescape, authzid +from conjur.util import urlescape, authzid, split_id from conjur.exceptions import ConjurException import logging @@ -38,34 +38,14 @@ class Role(object): `conjur.User` and `conjur.Group` objects have `role` members that reference the role corresponding to that Conjur asset. """ - def __init__(self, api, kind, identifier): - """ - Create a role to represent the Conjur role with id `:`. For - example, to represent the role associated with a user named bob, - - role = Role(api, 'user', 'bob') - - `api` must be a `conjur.API` instance, used to implement this classes interactions with Conjur - - `kind` is a string giving the role kind - - `identifier` is the unqualified identifier of the role. - """ - + def __init__(self, api, account=None, kind=None, id=None): self.api = api - """ - The `conjur.API` instance used to implement our methods. - """ - - self.kind = kind - """ - The `kind` portion of the role's id. - """ - - self.identifier = identifier - """ - The `identifier` portion of the role's id. - """ + [self.account, self.kind, self.identifier] = split_id(id) + self.account = self.account or account or api.config.account + self.kind = self.kind or kind + assert self.account and (not account or self.account == account) + assert self.kind and (not kind or self.kind == kind) + assert self.identifier @classmethod def from_roleid(cls, api, roleid): @@ -77,10 +57,7 @@ def from_roleid(cls, api, roleid): `roleid` is a fully or partially qualified Conjur identifier, for example, `"the-account:service:some-service"` or `"service:some-service"` resolve to the same role. """ - tokens = authzid(roleid, 'role').split(':', 3) - if len(tokens) == 3: - tokens.pop(0) - return cls(api, *tokens) + return cls(api, id=authzid(roleid, 'role')) @property def roleid(self): @@ -94,7 +71,7 @@ def roleid(self): 'the-account:user:bob' """ - return ':'.join([self.api.config.account, self.kind, self.identifier]) + return ':'.join([self.account, self.kind, self.identifier]) def is_permitted(self, resource, privilege): """ @@ -116,10 +93,10 @@ def is_permitted(self, resource, privilege): """ params = { 'check': 'true', - 'resource_id': authzid(resource, 'resource'), + 'resource': authzid(resource, 'resource'), 'privilege': privilege } - response = self.api.get(self._url(), params=params, + response = self.api.get(self.url(), params=params, check_errors=False) if response.status_code == 204: return True @@ -128,53 +105,63 @@ def is_permitted(self, resource, privilege): else: raise ConjurException("Request failed: %d" % response.status_code) - def grant_to(self, member, admin=None): - """ - Grant this role to `member`. - - `member` is a string or object with a `role` attribute or `roleid` method, - such as a `conjur.User` or `conjur.Group`. - - `admin` whether the member can grant this role to others. - - """ - data = {} - if admin is not None: - data['admin'] = 'true' if admin else 'false' - self.api.put(self._membership_url(member), data=data) - - def revoke_from(self, member): + def info(self): """ - The inverse of `conjur.Role.grant_to`. Removes `member` from the members of this - role. + Return role information. This will be a `dict` with the following keys: - `member` is a string or object with a `role` attribute or `roleid` method, - such as a `conjur.User` or `conjur.Group`. + * `'created_at'` timestamp of role creation (eg. last refresh of the + policy that creates it) + * `'id'` fully qualified role id + * `'members'` members of the role (see `members` for details) """ - self.api.delete(self._membership_url(member)) + return self.api.get(self.url()).json() def members(self): """ Return a list of members of this role. Members are returned as `dict`s with the following keys: - * `'member'` the fully qualified identifier of the group + * `'member'` the fully qualified identifier of the member * `'role'` the fully qualified identifier of the group (redundant) - * `'grantor'` the role that granted the membership * `'admin_option'` whether this member can grant membership in the group to other roles. """ - return self.api.get(self._membership_url()).json() - - def _membership_url(self, member=None): - url = self._url() + "?members" - if member is not None: - memberid = authzid(member, 'role') - url += "&member=" + urlescape(memberid) - return url - - def _url(self, *args): - return "/".join([self.api.config.authz_url, - self.api.config.account, + return self.info()['members'] + + def url(self, *args): + return "/".join([self.api.config.url, 'roles', + self.account, self.kind, self.identifier] + list(args)) + + def _public_keys_url(self): + return '/'.join([ + self.api.config.url, + 'public_keys', + self.account, + self.kind, + self.identifier + ]) + + def public_keys(self): + """ + Returns all SSH public keys for this role, if any, as a newline delimited string. + """ + return self.api.get(self._public_keys_url()).text.strip() + + def resource(self): + """ + Return the corresponding resource (ie. with the same id). + Note not every role will have one, so the + returned object may refer to a nonexistent resource. + """ + return self.api.resource_qualified(self.roleid) + + def rotate_api_key(self): + """ + Rotates the API key of a role + The calling role must either be the target role itself or have 'update' privilege on the target role + """ + return self.api.put( + '{}/authn/{}/api_key?role={}:{}'.format(self.api.config.url, self.account, self.kind, self.identifier) + ).text.strip() diff --git a/conjur/user.py b/conjur/user.py deleted file mode 100644 index b0cdb42..0000000 --- a/conjur/user.py +++ /dev/null @@ -1,49 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from conjur.util import urlescape - - -class User(object): - def __init__(self, api, login, attrs=None): - self.api = api - self.login = login - # support as_role - self.role = api.role('user', login) - self._attrs = attrs - - def exists(self): - resp = self.api.get(self.url(), check_errors=False) - return resp.status_code != 404 - - def __getattr__(self, item): - if self._attrs is None: - self._fetch() - try: - return self._attrs[item] - except KeyError: - raise AttributeError(item) - - def _fetch(self): - self._attrs = self.api.get(self.url()).json() - - def url(self): - return "{0}/users/{1}".format(self.api.config.core_url, - urlescape(self.login)) diff --git a/conjur/util.py b/conjur/util.py index 055972b..589302a 100644 --- a/conjur/util.py +++ b/conjur/util.py @@ -30,6 +30,20 @@ def urlescape(s): return quote(s, '') +def login_kind(login): + tokens = login.split('/', 1) + if len(tokens) == 2: + return tokens[0] + else: + return 'user' + +def login_identifier(login): + tokens = login.split('/', 1) + if len(tokens) == 2: + return tokens[1] + else: + return tokens[0] + def authzid(obj, kind, with_account=True): if isinstance(obj, (str, unicode)): # noqa F821 (flake8 doesn't know about unicode) if not with_account: @@ -39,3 +53,10 @@ def authzid(obj, kind, with_account=True): if hasattr(obj, attr): return authzid(getattr(obj, attr), kind) raise TypeError("Can't get {0}id from {1}".format(kind, obj)) + +def split_id(id): + """ + Return id split into [account, kind, id], any of which might be None. + """ + pieces = id.split(':', 2) + return [None] * (3 - len(pieces)) + pieces diff --git a/conjur/variable.py b/conjur/variable.py deleted file mode 100644 index e45a168..0000000 --- a/conjur/variable.py +++ /dev/null @@ -1,94 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from conjur.util import urlescape - - -class Variable(object): - """ - A `Variable` represents a versioned secret stored in Conjur. - - Generally you will get an instance of this class by calling `conjur.API.create_variable` - or `conjur.API.variable`. - - Instances of this class allow you to fetch values of the variable, and store new ones. - - Example: - - >>> # Print the current value of the variable `mysql-password` - >>> variable = api.variable('mysql-password') - >>> print("mysql-password is {}".format(variable.value())) - - Example: - - >>> # Print all versions of the same variable - >>> variable = api.variable('mysql-password') - >>> for i in range(1, variable.version_count + 1): # version numbers are 1 based - ... print("version {} of 'mysql-password' is {}".format(i, variable.value(i))) - - """ - def __init__(self, api, id, attrs=None): - self.id = id - self.api = api - self._attrs = attrs - - def value(self, version=None): - """ - Retrieve the secret stored in a variable. - - `version` is a *one based* index of the version to be retrieved. - - If no such version exists, a 404 error is raised. - - Returns the value of the variable as a string. - """ - url = "%s/variables/%s/value" % (self.api.config.core_url, - urlescape(self.id)) - if version is not None: - url = "%s?version=%s" % (url, version) - return self.api.get(url).text - - def add_value(self, value): - """ - Stores a new version of the secret in this variable. - - `value` is a string giving the new value to store. - - This increments the variable's `version_count` member by one. - """ - self._attrs = None - data = {'value': value} - url = "%s/variables/%s/values" % (self.api.config.core_url, - urlescape(self.id)) - self.api.post(url, data=data) - - def __getattr__(self, item): - if self._attrs is None: - self._fetch() - try: - return self._attrs[item] - except KeyError: - raise AttributeError(item) - - def _fetch(self): - self._attrs = self.api.get( - "{0}/variables/{1}".format(self.api.config.core_url, - urlescape(self.id)) - ).json() diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..740ccc6 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,28 @@ +# Possum Demo + +## Description + +See [https://conjurinc.github.io/possum/demo.html](https://conjurinc.github.io/possum/demo.html) for a detailed walkthrough. + +The policies used in the demo are available in this directory. + +## Running + +To run a Possum server and command-line client in Docker containers, simply run `./start.sh`: + +```sh-session +$ ./start.sh +pg uses an image, skipping +possum uses an image, skipping +Step 1 : FROM python:2.7-slim + ---> 4947dfe5e830 +... +Creating demo_pg_1 +Creating demo_possum_1 +root@5c2b6208380e:/app# possum -h +usage: possum [-h] + {login,whoami,authenticate,rotate_api_key,list,show,policy:load,store,fetch} + ... + +Possum command-line interface. +``` diff --git a/demo/bootstrap.yml b/demo/bootstrap.yml new file mode 100644 index 0000000..ff66806 --- /dev/null +++ b/demo/bootstrap.yml @@ -0,0 +1,32 @@ +- !group security_admin + +- !policy + id: people + owner: !group security_admin + body: + - !group + id: frontend + + - !group + id: operations + +- !policy + id: prod + owner: !group security_admin + body: + - !policy + id: frontend + + - !policy + id: database + owner: !group ../people/operations + +- !permit + role: !group people/frontend + privilege: [ read, execute ] + resource: !policy prod/frontend + +- !permit + role: !group people/operations + privilege: [ read, execute ] + resource: !policy prod/database diff --git a/demo/database.yml b/demo/database.yml new file mode 100644 index 0000000..96d8896 --- /dev/null +++ b/demo/database.yml @@ -0,0 +1,7 @@ +- &variables + - !variable password + +- !permit + role: !layer ../frontend + privilege: [ read, execute ] + resource: *variables diff --git a/demo/docker-compose.yml b/demo/docker-compose.yml new file mode 100644 index 0000000..2652eea --- /dev/null +++ b/demo/docker-compose.yml @@ -0,0 +1,23 @@ +pg: + image: postgres:9.3 + +possum: + image: conjurinc/possum + command: server -a demo -f /var/lib/possum/initial_bootstrap.yml + environment: + DATABASE_URL: postgres://postgres@pg/postgres + POSSUM_ADMIN_PASSWORD: secret + POSSUM_DATA_KEY: + volumes: + - .:/var/lib/possum + links: + - pg:pg + +client: + build: .. + entrypoint: bash + env_file: env + volumes: + - ..:/app + links: + - possum:possum diff --git a/demo/env b/demo/env new file mode 100644 index 0000000..2637dd3 --- /dev/null +++ b/demo/env @@ -0,0 +1,3 @@ +CONJUR_URL=http://possum +CONJUR_ACCOUNT=demo +PYTHONPATH=/app diff --git a/demo/frontend.yml b/demo/frontend.yml new file mode 100644 index 0000000..b56dc62 --- /dev/null +++ b/demo/frontend.yml @@ -0,0 +1,9 @@ +- !layer + +- &hosts + - !host frontend-01 + - !host frontend-02 + +- !grant + role: !layer + members: *hosts diff --git a/demo/generate.py b/demo/generate.py new file mode 100755 index 0000000..878c886 --- /dev/null +++ b/demo/generate.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +import sys + +count = int(sys.argv[1]) + +f = open('generated-policy.yml', 'w') + +for i in range(0, count / 10 + 1): + f.write("- !group group-%s\n" % i) + +f.write("\n") + +for i in range(0, count): + f.write("""- !user user-%s +- !grant + role: !group group-%s + member: !user user-%s +\n""" % ( i, i / 10, i)) + +f.close() diff --git a/demo/initial_bootstrap.yml b/demo/initial_bootstrap.yml new file mode 100644 index 0000000..1070144 --- /dev/null +++ b/demo/initial_bootstrap.yml @@ -0,0 +1,9 @@ +- !group security_admin + +- !policy + id: people + owner: !group security_admin + +- !policy + id: prod + owner: !group security_admin diff --git a/demo/people.yml b/demo/people.yml new file mode 100644 index 0000000..9011274 --- /dev/null +++ b/demo/people.yml @@ -0,0 +1,16 @@ +- !group operations + +- !group frontend + +- !user owen + +- !user frank + +- !grant + role: !group operations + member: !user owen + +- !grant + role: !group frontend + member: !user frank + diff --git a/demo/start.sh b/demo/start.sh new file mode 100755 index 0000000..390a609 --- /dev/null +++ b/demo/start.sh @@ -0,0 +1,13 @@ +#!/bin/bash -e + +docker-compose build + +if [ ! -f data_key ]; then + echo "Generating data key" + docker-compose run --no-deps --rm possum data-key generate > data_key +fi + +export POSSUM_DATA_KEY="$(cat data_key)" + +docker-compose up -d pg possum +docker-compose run --rm client diff --git a/demo/stop.sh b/demo/stop.sh new file mode 100755 index 0000000..1e3ef9d --- /dev/null +++ b/demo/stop.sh @@ -0,0 +1,3 @@ +#!/bin/bash -ex + +docker-compose down diff --git a/dev/Dockerfile b/dev/Dockerfile new file mode 100644 index 0000000..a590b53 --- /dev/null +++ b/dev/Dockerfile @@ -0,0 +1,6 @@ +FROM ubuntu:14.04 + +RUN apt-get update -y && apt-get install -y git curl vim python-pip + +WORKDIR /app +ENV PYTHONPATH /app diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml new file mode 100644 index 0000000..866f5ff --- /dev/null +++ b/dev/docker-compose.yml @@ -0,0 +1,32 @@ +pg: + image: postgres:9.3 + +example: + image: conjurinc/possum-example + entrypoint: /bin/sh + +possum: + image: conjurinc/possum + command: server -a example -f /var/lib/possum-example/policy/conjur.yml + environment: + DATABASE_URL: postgres://postgres@pg/postgres + POSSUM_ADMIN_PASSWORD: secret + POSSUM_DATA_KEY: + volumes_from: + - example + links: + - pg:pg + +client: + build: . + entrypoint: bash + environment: + CONJUR_APPLIANCE_URL: http://possum + CONJUR_ACCOUNT: example + POSSUM_LOGIN: admin + POSSUM_PASSWORD: secret + PYTHONPATH: /app + volumes: + - ..:/app + links: + - possum:possum diff --git a/dev/start.sh b/dev/start.sh new file mode 100755 index 0000000..258e6d2 --- /dev/null +++ b/dev/start.sh @@ -0,0 +1,13 @@ +#!/bin/bash -ex + +docker-compose build + +if [ ! -f data_key ]; then + echo "Generating data key" + docker-compose run --no-deps --rm possum data-key generate > data_key +fi + +export POSSUM_DATA_KEY="$(cat data_key)" + +docker-compose up -d pg example possum +docker-compose run --rm client diff --git a/dev/stop.sh b/dev/stop.sh new file mode 100755 index 0000000..f9a246a --- /dev/null +++ b/dev/stop.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e + +docker-compose stop +docker-compose rm -f diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b31ad7f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,109 @@ +# Python API examples + +These examples show how to use the Python API. + +## petstore + +An end-to-end example that illustrates fetching secrets using host identity +and enabling traffic authorization can be found in the [petstore](petstore) +subdirectory. + +Read on for other examples of how to use the Conjur Python API client. + +--- + +A docker-compose environment is included for ease of use. Note it needs +[conjurinc/possum-example](https://github.com/conjurinc/possum-example) +and [conjurinc/possum](https://github.com/conjurinc/possum) images available +in docker repository. + +To start, use `./start.sh`: + +```sh-session +$ ./start.sh ++ docker-compose build +pg uses an image, skipping +example uses an image, skipping +possum uses an image, skipping +Building api +[...] ++ POSSUM_DATA_KEY=40jLjbr3O2n1Z//MgU6G3SzFVjuO/fv6zQkeyzu1sxU= ++ docker-compose up -d pg possum +Creating examples_example_1 +Creating examples_pg_1 +Creating examples_possum_1 ++ docker-compose run --rm api +Starting examples_example_1 +root@0d85ff261d5d:/app# +``` + +## directory.py + +Logs in, lists groups and their members, then users and their public keys: + +```sh-session +# python examples/directory.py +========================================================================= +('Base url :', 'http://possum.example') +('Account :', 'example') +('Login :', 'admin') +('Password :', 'secret') +========================================================================= +Group example:group:security_admin; members: + - example:user:admin +Group example:group:field-admin; members: + - example:group:security_admin + - example:user:kyle.wheeler + - example:user:marin.dubois +[...] +User example:user:kyle.wheeler +User example:user:marin.dubois +User example:user:carol.rodriquez +[...] +``` + +## secrets.py + +Logs in, sets secrets on all resources with kind 'variable' and id like +'*password*' to a random value, then lists all the secrets. + +```sh-session +# python examples/secrets.py +========================================================================= +('Base url :', 'http://possum.example') +('Account :', 'example') +('Login :', 'admin') +('Password :', 'secret') +========================================================================= +Setting example:variable:prod/analytics/v1/redshift/master_user_password = jdPa8)sOW#XM +Setting example:variable:prod/frontend/v1/mongo/password = ,xV:An%3cmSE +Setting example:variable:prod/user-database/v1/postgres/master_user_password = (Y`{5(y3uRUK +[...] +example:variable:prod/user-database/v1/postgres/master_user_name = None +example:variable:prod/user-database/v1/postgres/master_user_password = (Y`{5(y3uRUK +example:variable:prod/user-database/v1/postgres/database_name = None +example:variable:prod/user-database/v1/postgres/database_url = None +``` + +## authorization.py and authorization_client.py + +A web server authorizing with possum resource: + +```sh-session +# python examples/authorization.py & +[1] 14 +Serving on port 8000... +# python examples/authorization_client.py +127.0.0.1 - - [15/Sep/2016 14:19:13] "GET / HTTP/1.1" 200 24 +200 OK +You are authorized!!!!! +``` + +In the Possum server log, you'll see the authorization check: + +``` +possum_1 | Started GET "/resources/example/webservice/prod/analytics/v1?privilege=execute&check=true" for 172.17.0.7 at 2016-09-15 14:32:55 +0000 +possum_1 | Processing by ResourcesController#check_permission as */* +possum_1 | Parameters: {"privilege"=>"execute", "check"=>"true", "account"=>"example", "kind"=>"webservice", "identifier"=>"prod/analytics/v1"} +possum_1 | Completed 204 No Content in 3ms +``` diff --git a/examples/authorization.py b/examples/authorization.py new file mode 100644 index 0000000..4112988 --- /dev/null +++ b/examples/authorization.py @@ -0,0 +1,28 @@ +from wsgiref.simple_server import make_server +import conjur + +conjur.config.update( + url = "http://possum.example", + account = "example" +) + +possum_resource = 'example:webservice:prod/analytics/v1' + +def simple_app(environ, start_response): + # use authorization header supplied by the client + possum = conjur.new_from_header(environ['HTTP_AUTHORIZATION']) + + if not possum.resource_qualified(possum_resource).permitted('execute'): + start_response("403 Forbidden", []) + return ["Forbidden\r\n"] + else: + status = '200 OK' + headers = [('Content-type', 'text/plain')] + + start_response(status, headers) + + return 'You are authorized!!!!!\n' + +httpd = make_server('', 8000, simple_app) +print "Serving on port 8000..." +httpd.serve_forever() diff --git a/examples/authorization_client.py b/examples/authorization_client.py new file mode 100644 index 0000000..c28a00b --- /dev/null +++ b/examples/authorization_client.py @@ -0,0 +1,16 @@ +import conjur +import httplib + +conjur.config.update( + url = "http://possum.example", + account = "example" +) + +conn = httplib.HTTPConnection("localhost:8000") + +possum = conjur.new_from_password('admin', 'secret') +conn.request("GET", "/", None, {"Authorization": possum.auth_header()}) + +response = conn.getresponse() +print response.status, response.reason +print response.read() diff --git a/examples/directory.py b/examples/directory.py new file mode 100644 index 0000000..b7b13e2 --- /dev/null +++ b/examples/directory.py @@ -0,0 +1,31 @@ +import conjur +import os + +possum_url = os.environ['POSSUM_URL'] +possum_account = os.environ['POSSUM_ACCOUNT'] +possum_login = os.environ['POSSUM_LOGIN'] +possum_password = os.environ['POSSUM_PASSWORD'] + +print('========================================================================='); +print('Base url :', possum_url); +print('Account :', possum_account); +print('Login :', possum_login); +print('Password :', possum_password); +print('========================================================================='); + +conjur.config.url = possum_url +conjur.config.account = possum_account + +client = conjur.new_from_password(possum_login, possum_password) + +for group in client.resources(kind='group'): + print("Group {}; members:".format(group.resourceid)) + for mem in group.role().members(): + roleid = mem["member"] + print(" - {}".format(roleid)) + +for user in client.resources(kind='user'): + print("User {}".format(user.resourceid)) + keys = user.role().public_keys() + if len(keys): + print(" public keys:\n{}".format(keys)) diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml new file mode 100644 index 0000000..282dadf --- /dev/null +++ b/examples/docker-compose.yml @@ -0,0 +1,29 @@ +pg: + image: postgres:9.3 + +example: + image: conjurinc/possum-example + entrypoint: /bin/sh + +possum: + image: conjurinc/possum + command: server -a example -f /var/lib/possum-example/policy/conjur.yml + environment: + DATABASE_URL: postgres://postgres@pg/postgres + POSSUM_ADMIN_PASSWORD: secret + POSSUM_DATA_KEY: + volumes_from: + - example + links: + - pg:pg + +api: + build: .. + entrypoint: bash + environment: + CONJUR_APPLIANCE_URL: http://possum + env_file: env + volumes: + - ..:/app + links: + - possum:possum.example diff --git a/examples/env b/examples/env new file mode 100644 index 0000000..1ce5f77 --- /dev/null +++ b/examples/env @@ -0,0 +1,5 @@ +POSSUM_URL=http://possum.example +POSSUM_ACCOUNT=example +POSSUM_LOGIN=admin +POSSUM_PASSWORD=secret +PYTHONPATH=/app diff --git a/examples/petstore/README.md b/examples/petstore/README.md new file mode 100644 index 0000000..4d5c544 --- /dev/null +++ b/examples/petstore/README.md @@ -0,0 +1,115 @@ +# Pet Store - Using Possum with Python's Flask web framework + +This example project illustrates how to: + +* Fetch a secret (database password) using the [Conjur Python API client](https://pypi.python.org/pypi/Conjur) +* Authorize traffic from other clients that want to call this web service + +The scenario for this example: + +> A local pet store wants to be able to display all pets that they have available to the general public. +Pet info is fetched from a PostgreSQL database and displayed on a website page. The pet store wants to be able +to add and remove pets from their inventory securely. Only their employees should be able to add pets. +The pet store also has an inventory manager, another web service that can be used to add or remove pets as needed. +When the `pet store` service receives a request from `employees` or the `inventory manager`, it authenticates and authorizes the +request with Possum. + +![Pet store diagram](http://i.imgur.com/HLSO2VB.png) + +## Requirements + +* Docker and docker-compose +* Python 2.7+ and pip + +## Example + +First, set up your environment. + +``` +./start.sh +``` + +Possum has now loaded [policy.yml](policy.yml) and is running and listening on +local port `3030`. For this example, the `admin` user's password is +`secret`. + +The `petstore` database has also been created, with user `petstore`. +The database user's password has been loaded into the `dbpassword` variable +in Possum with [load_secrets.py](load_secrets.py). + +Now that Possum is running, run the Flask app from this directory: + +``` +pip install -r requirements.txt +python app.py +``` + +The Flask web app is now listening on port `8080`. +Open [localhost:8080](http://localhost:8080) in your browser. +You will notice that there are no pets displayed. + +## Policy + +[policy.yml](policy.yml) is loaded when Possum starts up. The policy defines: + +* variable `dbpassword` - Variable resource holds, the `petstore` database password +* host `petstore` - Host role for the `petstore` app +* host `inventory_manager` - Host role for the `inventory_manager` app +* group `employees` - Group role with three users + +The host `petstore` is granted `execute` (read) access to the `dbpassword` variable. `petstore` fetches this password from Possum when starting up. +`inventory_manager` is granted `add_pet` and `remove_pet` privileges on the`petstore` host. +Members of group `employees` are granted `add_pet` permission on the `petstore` host. Removing pets is a more privileged operation than adding them. + +Pets can be added and removed by using the pet store's API. + +* add pet: `POST` `/api/pets`, JSON body with `name` and `type` fields +* remove pet: `DELETE` `/api/pets/` with pet ID + +The add and remove views are protected with the `validate_privilege` decorator +in [app.py](app.py). A user or machine must pass an `Authorization` header +when calling the `petstore` host. `petstore` consults Possum to ensure +that the caller has the required privilege on the `petstore` host. +If so, the request proceeds. If not, an error message is returned. + +## Demo + +Simulate calling the `petstore` host as different identities: + +```sh-session +# non-employee +$ python nonemployee.py +Adding pet +401: {u'msg': u'Authorization header missing', u'ok': False} + +# employee +$ python employee.py +Adding pet +201: {u'ok': True, u'id': 7} +Removing pet +403: {u'msg': u'Not authorized', u'ok': False} + +# inventory_manager +$ python inventory_manager.py +Adding pet +201: {u'ok': True, u'id': 8} +Removing pet +200: {u'ok': True, u'id': u'8'} +``` + +As expected, non-employees cannot update the pet inventory at all. Users in group +`employees` can add pets, but not remove them. Finally, the `inventory_manager` service +can add and remove pets. + +To stop the possum local environment run: + +``` +./stop.sh +``` + +## Conclusion + +In this example, we simulated a pet store system where anyone can view the +pets available but only trusted people and machines are allowed to manage +pet inventory. Different tiers of access are easily defined and implemented +using Possum's YAML policy. diff --git a/examples/petstore/app.py b/examples/petstore/app.py new file mode 100644 index 0000000..b253d9a --- /dev/null +++ b/examples/petstore/app.py @@ -0,0 +1,100 @@ +from functools import wraps +import sys + +from flask import Flask, render_template, jsonify, request +from flask_sqlalchemy import SQLAlchemy + +sys.path.append('../..') +import conjur + +app = Flask(__name__) + +conjur.config.url = 'http://localhost:3030' +conjur.config.account = 'example' + +api = conjur.new_from_password('admin', 'secret') +key = api.role('host', 'petstore').rotate_api_key() +api = conjur.new_from_key('host/petstore', key) + +app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://petstore:{}@localhost/petstore'.format( + api.resource('variable', 'dbpassword').secret() +) +db = SQLAlchemy(app) + + +class Pets(db.Model): + __tablename__ = 'pets' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80)) + type = db.Column(db.String(80), index=True) + + def __init__(self, name, type): + self.name = name + self.type = type + + def __repr__(self): + return ''.format(self.name, self.type) + +db.create_all() + + +# This decorater validates that the user/host calling the route has privilege to do so +# Arguments +# - resource: Kind and ID of a Possum resource, separated by : - example 'variable:dbpassword' +# - privilege: The privilege the callers needs on the resource to be allowed to call the route +def validate_privilege(resource, privilege): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + auth_token = request.headers.get('AUTHORIZATION') + if auth_token is None: + return jsonify({'ok': False, 'msg': 'Authorization header missing'}), 401 + + _api = conjur.new_from_header(auth_token) + kind, identifier = resource.split(':') + if not _api.resource(kind=kind, identifier=identifier).permitted(privilege): + return jsonify({'ok': False, 'msg': 'Not authorized'}), 403 + + return f(*args, **kwargs) + return decorated_function + return decorator + + +@app.route('/') +def home(): + return render_template('home.html', pets=Pets.query.all()) + + +# API routes + +@app.route('/api/pets', methods=['POST']) +@validate_privilege('host:petstore', 'add_pet') +def add_pet(): + json = request.get_json(force=True) + valid = json.has_key('name') and json.has_key('type') + + if not valid: + return jsonify({'ok': False, 'id': None, 'msg': "'name' or 'type' missing from JSON body"}), 400 + + pet = Pets(json['name'], json['type']) + db.session.add(pet) + db.session.commit() + + return jsonify({'ok': True, 'id': pet.id}), 201 + + +@app.route('/api/pets/', methods=['DELETE']) +@validate_privilege('host:petstore', 'remove_pet') +def remove_pet(id): + pet = Pets.query.filter_by(id=id).first() + + if pet is None: + return jsonify({'ok': False, 'id': id, 'msg': 'Pet ID {} not found'.format(id)}), 404 + + db.session.delete(pet) + db.session.commit() + + return jsonify({'ok': True, 'id': id}) + +if __name__ == '__main__': + app.run('0.0.0.0', 8080, debug=True) diff --git a/examples/petstore/docker-compose.yml b/examples/petstore/docker-compose.yml new file mode 100644 index 0000000..8e86465 --- /dev/null +++ b/examples/petstore/docker-compose.yml @@ -0,0 +1,25 @@ +version: '2' +services: + appdb: + image: postgres:9.3 + ports: + - "5432:5432" + volumes: + - ./init-user-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh + + possum: + image: conjurinc/possum + ports: + - "3030:80" + command: server -a example -f /src/policy.yml + volumes: + - .:/src + environment: + DATABASE_URL: postgres://postgres@possumdb/postgres + POSSUM_ADMIN_PASSWORD: secret + POSSUM_DATA_KEY: + depends_on: + - possumdb + + possumdb: + image: postgres:9.3 diff --git a/examples/petstore/employee.py b/examples/petstore/employee.py new file mode 100644 index 0000000..aed048a --- /dev/null +++ b/examples/petstore/employee.py @@ -0,0 +1,35 @@ +# Simulates an employee trying to add and remove a pet + +import sys + +import requests + +sys.path.append('../..') +import conjur + +PETSTORE_URL = 'http://localhost:8080' + +conjur.config.url = 'http://localhost:3030' +conjur.config.account = 'example' + +api = conjur.new_from_password('admin', 'secret') +key = api.role('user', 'dan').rotate_api_key() +api = conjur.new_from_key('dan', key) + + +print 'Adding pet' +response = requests.post( + '{}/api/pets'.format(PETSTORE_URL), + json={'name': 'Clarence', 'type': 'Fur Seal'}, + headers={'Authorization': api.auth_header()} +) + +print '{}: {}'.format(response.status_code, response.json()) + +print 'Removing pet' +response = requests.delete( + '{}/api/pets/{}'.format(PETSTORE_URL, response.json()['id']), + headers={'Authorization': api.auth_header()} +) + +print '{}: {}'.format(response.status_code, response.json()) diff --git a/examples/petstore/init-user-db.sh b/examples/petstore/init-user-db.sh new file mode 100755 index 0000000..20b81af --- /dev/null +++ b/examples/petstore/init-user-db.sh @@ -0,0 +1,7 @@ +#!/bin/bash -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE USER petstore WITH PASSWORD 'w^kftUagHmF2Ahph'; + CREATE DATABASE petstore; + GRANT ALL PRIVILEGES ON DATABASE petstore TO petstore; +EOSQL diff --git a/examples/petstore/inventory_manager.py b/examples/petstore/inventory_manager.py new file mode 100644 index 0000000..ffa55d5 --- /dev/null +++ b/examples/petstore/inventory_manager.py @@ -0,0 +1,33 @@ +# Simulates host inventory-manager trying to add and remove a pet + +import sys + +import requests + +sys.path.append('../..') +import conjur + +PETSTORE_URL = 'http://localhost:8080' + +conjur.config.url = 'http://localhost:3030' +conjur.config.account = 'example' + +api = conjur.new_from_password('admin', 'secret') +key = api.role('host', 'inventory_manager').rotate_api_key() +api = conjur.new_from_key('host/inventory_manager', key) + +response = requests.post( + '{}/api/pets'.format(PETSTORE_URL), + json={'name': 'Spot', 'type': 'Beagle'}, + headers={'Authorization': api.auth_header()} +) + +print '{}: {}'.format(response.status_code, response.json()) + +print 'Removing pet' +response = requests.delete( + '{}/api/pets/{}'.format(PETSTORE_URL, response.json()['id']), + headers={'Authorization': api.auth_header()} +) + +print '{}: {}'.format(response.status_code, response.json()) \ No newline at end of file diff --git a/examples/petstore/load_secrets.py b/examples/petstore/load_secrets.py new file mode 100644 index 0000000..418da88 --- /dev/null +++ b/examples/petstore/load_secrets.py @@ -0,0 +1,13 @@ +import sys + +sys.path.append('../..') +import conjur + + +conjur.config.url = 'http://localhost:3030' +conjur.config.account = 'example' + +api = conjur.new_from_password('admin', 'secret') + +# Set the database password to a known value +api.resource('variable', 'dbpassword').add_secret('w^kftUagHmF2Ahph') diff --git a/examples/petstore/nonemployee.py b/examples/petstore/nonemployee.py new file mode 100644 index 0000000..b8a18ec --- /dev/null +++ b/examples/petstore/nonemployee.py @@ -0,0 +1,13 @@ +# Simulates Kate, a non-employee trying to add a pet to the pet store + +import requests + +PETSTORE_URL = 'http://localhost:8080' + +print 'Adding pet' +response = requests.post( + '{}/api/pets'.format(PETSTORE_URL), + json={'name': 'Johnny', 'type': 'Gibbon'} +) + +print '{}: {}'.format(response.status_code, response.json()) diff --git a/examples/petstore/policy.yml b/examples/petstore/policy.yml new file mode 100644 index 0000000..53a72bf --- /dev/null +++ b/examples/petstore/policy.yml @@ -0,0 +1,31 @@ +--- +- !variable &dbpassword dbpassword + +- !host &petstore petstore +- !host &inventory_manager inventory_manager + +- !group &employees_group employees + +- &employees + - !user dan + - !user lisa + - !user jamal + +- !grant + role: *employees_group + members: *employees + +- !permit + role: *employees + resource: *petstore + privileges: [add_pet] + +- !permit + role: *inventory_manager + resource: *petstore + privileges: [add_pet, remove_pet] + +- !permit + role: *petstore + resource: *dbpassword + privileges: [execute] diff --git a/examples/petstore/requirements.txt b/examples/petstore/requirements.txt new file mode 100644 index 0000000..0a8febc --- /dev/null +++ b/examples/petstore/requirements.txt @@ -0,0 +1,5 @@ +Flask +psycopg2 +Flask-SQLAlchemy + +# conjur - this library is being loaded locally diff --git a/examples/petstore/start.sh b/examples/petstore/start.sh new file mode 100755 index 0000000..ca02784 --- /dev/null +++ b/examples/petstore/start.sh @@ -0,0 +1,16 @@ +#!/bin/bash -e + +docker-compose build + +if [ ! -f data_key ]; then + echo "Generating data key" + docker-compose run --no-deps --rm possum data-key generate > data_key +fi + +export POSSUM_DATA_KEY="$(cat data_key)" + +docker-compose up -d + +sleep 15 # a better way to do this? + +python load_secrets.py diff --git a/examples/petstore/stop.sh b/examples/petstore/stop.sh new file mode 100755 index 0000000..9a8718d --- /dev/null +++ b/examples/petstore/stop.sh @@ -0,0 +1,3 @@ +#!/bin/bash -e + +docker-compose down -v diff --git a/examples/petstore/templates/home.html b/examples/petstore/templates/home.html new file mode 100644 index 0000000..a4d9ccc --- /dev/null +++ b/examples/petstore/templates/home.html @@ -0,0 +1,31 @@ + + + Pets + + + +
+

Pets

+ + + + + + + + + + + {% for pet in pets %} + + + + + + {% endfor %} + +
IDNameType
{{pet.id}}{{pet.name}}{{pet.type}}
+
+ + + diff --git a/examples/secrets.py b/examples/secrets.py new file mode 100644 index 0000000..b74fdd1 --- /dev/null +++ b/examples/secrets.py @@ -0,0 +1,42 @@ +import conjur +import os +import random +import string + +possum_url = os.environ['POSSUM_URL'] +possum_account = os.environ['POSSUM_ACCOUNT'] +possum_login = os.environ['POSSUM_LOGIN'] +possum_password = os.environ['POSSUM_PASSWORD'] + +print('========================================================================='); +print('Base url :', possum_url); +print('Account :', possum_account); +print('Login :', possum_login); +print('Password :', possum_password); +print('========================================================================='); + +conjur.config.url = possum_url +conjur.config.account = possum_account + +client = conjur.new_from_password(possum_login, possum_password) + +def random_password(): + # NOTE: just for example purposes. + # Use strong crypto to generate actual passwords! + return ''.join([random.choice(string.digits + string.letters + string.punctuation) for _ in range(12)]) + +def populate_some_variables(api): + for password in [ + var for var in api.resources(kind='variable') + if 'password' in var.identifier + ]: + pwd = random_password() + print("Setting {} = {}".format(password.resourceid, pwd)) + password.add_secret(pwd) + +def print_all_vars(api): + for var in api.resources(kind='variable'): + print("{} = {}".format(var.resourceid, var.secret())) + +populate_some_variables(client) +print_all_vars(client) diff --git a/examples/start.sh b/examples/start.sh new file mode 100755 index 0000000..a4d6fc2 --- /dev/null +++ b/examples/start.sh @@ -0,0 +1,13 @@ +#!/bin/bash -ex + +docker-compose build + +if [ ! -f data_key ]; then + echo "Generating data key" + docker-compose run --no-deps --rm possum data-key generate > data_key +fi + +export POSSUM_DATA_KEY="$(cat data_key)" + +docker-compose up -d pg possum +docker-compose run --rm api diff --git a/examples/stop.sh b/examples/stop.sh new file mode 100755 index 0000000..1e3ef9d --- /dev/null +++ b/examples/stop.sh @@ -0,0 +1,3 @@ +#!/bin/bash -ex + +docker-compose down diff --git a/features/environment.py b/features/environment.py index 49fbcbf..d8aba89 100644 --- a/features/environment.py +++ b/features/environment.py @@ -36,19 +36,16 @@ def conjur_env(name, default=None): def api(): config = Config( account=conjur_env('account', 'cucumber'), - appliance_url=conjur_env('appliance_url', 'https://conjur/api'), + url=conjur_env('url', 'http://possum.test'), cert_file=conjur_env('cert_file', '/opt/conjur/etc/ssl/conjur.pem') ) print("config={}".format(repr(config._config))) - if not os.path.exists(config.cert_file): - raise Exception("Missing cert file at {}".format(config.cert_file)) + login = 'alice' + password = 'secret' - login = conjur_env('authn_login', 'admin') - password = conjur_env('admin_password', 'secret') - - return conjur.new_from_key(login, password, config) + return conjur.new_from_password(login, password, config) def random_string(prefix, size=8): diff --git a/features/groups.feature b/features/groups.feature index 825b457..dc19267 100644 --- a/features/groups.feature +++ b/features/groups.feature @@ -1,23 +1,5 @@ Feature: Group Management - Scenario: I can create groups - When I try to create a group "foo" - Then it succeeds - Scenario: I can list group memberships - When I create a group "bar" - Then I can list the group members - - Scenario: I can add members to a group - When I create a group "developers" - And I create a user "bob" - And I add the user to the group - Then the user is a member of the group - - Scenario: I can remove members from a group - When I create a group "test" - And I create a user "bill" - And I add the user to the group - Then the user is a member of the group - When I remove the user from the group - Then the user is not a member of the group + When I list members of "group:everyone" + Then "cucumber:user:alice" is a member diff --git a/features/permissions.feature b/features/permissions.feature index ed3cb8e..fa95d4f 100644 --- a/features/permissions.feature +++ b/features/permissions.feature @@ -1,9 +1,8 @@ Feature: I can check permissions from Python code. Scenario: Check some predefined permissions + Given the preloaded policy Then "job:programmer" can not "fry" "food:bacon" - And "job:programmer" can "eat" "food:bacon" - And "job:cook" can "fry" "food:bacon" - And "job:cook" can not "eat" "food:bacon" - And the current role can "eat" "food:bacon" - And the current role can "fry" "food:bacon" - But the current role can not "eat" "food:does-not-exist" + And "user:alice" can "execute" "webservice:the-service" + And the current role can "execute" "webservice:the-service" + And the current role can "update" "webservice:the-service" + But the current role can not "update" "variable:db-password" diff --git a/features/policy/conjur.yml b/features/policy/conjur.yml new file mode 100644 index 0000000..b65c691 --- /dev/null +++ b/features/policy/conjur.yml @@ -0,0 +1,23 @@ +--- +- !user test + +- !webservice the-service + +- !user + id: alice + owner: !user test + public_keys: + - ssh-rsa AAAAB3NzHhIqxF alice@home + +- !group everyone + +- !variable db-password + +- !grant + role: !group everyone + member: !user alice + +- !permit + role: !user alice + resource: !webservice the-service + privilege: [update, execute] diff --git a/features/steps/group_steps.py b/features/steps/group_steps.py index 93bf107..95be391 100644 --- a/features/steps/group_steps.py +++ b/features/steps/group_steps.py @@ -1,64 +1,12 @@ from behave import when, then -def group_has_member(group, user): - members = group.members() - userid = user.role.roleid - for m in members: - if m['member'] == userid: - return True - return False - - -@when('I create a group "{name}"') -def create_group_impl(context, name): - name = context.random_string(name) - context.group = context.api.create_group(name) - - -@when('I try to create a group "{name}"') -def try_create_group_impl(context, name): - name = context.random_string(name) - context.create_group_failed = False - try: - context.group = context.api.create_group(name) - except Exception as e: - context.create_group_failed = e - - -@when('I add the user to the group') -def add_user_to_group(context): - context.group.add_member(context.user) - - -@when('I remove the user from the group') -def remove_user_from_group(context): - context.group.remove_member(context.user) - - -@then('The user is a member of the group') -def user_is_a_member_of_group(context): - assert group_has_member(context.group, context.user) - - -@then('the user is not a member of the group') -def user_is_not_a_member_of_group(context): - assert not group_has_member(context.group, context.user) - - -@then('it succeeds') -def it_succeeds_impl(context): - if context.create_group_failed: - err = context.create_group_failed - context.create_group_failed = False - assert not err - - -@then('I can list the group members') -def list_members_impl(context): +@when(u'I list members of "{kind}:{name}"') +def list_members_impl(context, kind, name): + context.group = context.api.role(kind, name) context.group_members = context.group.members() - -@when('I add the member to the group') -def add_member_impl(context): - context.group.add_member(context.user.role) +@then(u'"{id}" is a member') +def is_group_member(context, id): + print(context.group_members) + assert id in (x['member'] for x in context.group_members) diff --git a/features/steps/permission_steps.py b/features/steps/permission_steps.py index 6fc8372..3a721d5 100644 --- a/features/steps/permission_steps.py +++ b/features/steps/permission_steps.py @@ -11,7 +11,9 @@ def check_permission_current_impl(api, privilege, resource, can): resource = api.resource(*resource.split(':')) assert resource.permitted(privilege) == can - +@given(u'the preloaded policy') +def preloaded_policy(context): + pass @then('"{role}" can "{privilege}" "{resource}"') def role_can_resource(context, role, privilege, resource): diff --git a/features/users.feature b/features/users.feature deleted file mode 100644 index a999a02..0000000 --- a/features/users.feature +++ /dev/null @@ -1,9 +0,0 @@ -Feature: User management - - Scenario: I can create a user and authenticate with its credentials - When I create a user - Then I can login as the user using the api key - - Scenario: I can create a user with a password - When I create a user with a password - Then I can login as the user using the password \ No newline at end of file diff --git a/features/variables.feature b/features/variables.feature deleted file mode 100644 index 076eca8..0000000 --- a/features/variables.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: Variable manipuation - - Scenario: I can create a variable and add a value to it - When I create a variable - And I add a value "foo" - Then the variable should have attribute "version_count" with value 1 - And the variable should have value "foo" - diff --git a/jenkins.sh b/jenkins.sh index b270e4f..d8ec5f3 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -1,49 +1,83 @@ #!/bin/bash -ex -function cleanup { - docker rm -f $(cat conjur-cid) - rm conjur-cid +CONJUR_VERSION=${CONJUR_VERSION:-"latest"} +DOCKER_IMAGE=${DOCKER_IMAGE:-"conjurinc/possum:$CONJUR_VERSION"} +NOKILL=${NOKILL:-"0"} +PULL=${PULL:-"1"} +CMD_PREFIX="" + +function finish { + # Stop and remove the Conjur container if env var NOKILL != "1" + if [ "$NOKILL" != "1" ]; then + docker rm -f ${pg} || true + docker rm -f ${cid} || true + fi } +trap finish EXIT -if [ -z "$KEEP" ] ; then - trap cleanup EXIT +job=$JOB_NAME +if [ -z $job ]; then + job=sandbox fi -APPLIANCE_VERSION=4.8-stable +tag=api-python:$job +docker build -t api-python:$job . -rm -rf artifacts +rm -rf report +mkdir report -docker build -t api-python . - -docker run -d \ - --cidfile=conjur-cid \ - --privileged \ - -p 443:443 \ - -v ${PWD}/ci:/ci \ - --add-host=conjur:127.0.0.1 \ - registry.tld/conjur-appliance-cuke-master:$APPLIANCE_VERSION - -docker exec $(cat conjur-cid) /opt/conjur/evoke/bin/wait_for_conjur +if [ "$PULL" == "1" ]; then + docker pull $DOCKER_IMAGE +fi +if [ ! -f data_key ]; then + echo "Generating data key" + docker run --rm ${DOCKER_IMAGE} data-key generate > data_key +fi -mkdir -p ${PWD}/certs +export POSSUM_DATA_KEY="$(cat data_key)" -docker cp $(cat conjur-cid):/opt/conjur/etc/ssl/cuke-master.pem ${PWD}/certs +pg=$(docker run -d postgres:9.3) -docker exec $(cat conjur-cid) /ci/setup.sh +# Launch and configure a Conjur container +cid=$(docker run -d \ + -e DATABASE_URL=postgresql://postgres@pg/postgres \ + -e POSSUM_DATA_KEY \ + -e POSSUM_ADMIN_PASSWORD=secret \ + -e CONJUR_PASSWORD_ALICE=secret \ + -v $PWD/features/policy:/run/possum/policy/ \ + --link ${pg}:pg \ + ${DOCKER_IMAGE} \ + server -a cucumber -f /run/possum/policy/conjur.yml) +>&2 echo "Container id:" +>&2 echo $cid docker run --rm -Pi \ - -v ${PWD}/certs:/certs \ + -v ${PWD}:/app \ -v ${PWD}/artifacts:/artifacts \ - --link $(cat conjur-cid):conjur \ -api-python sh <= 2.2.1 \ No newline at end of file +requests >= 2.2.1 +tabulate +inflection diff --git a/tests/api_test.py b/tests/api_test.py index e827973..67e885d 100644 --- a/tests/api_test.py +++ b/tests/api_test.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2014 Conjur Inc +# Copyright (C) 2014-2016 Conjur Inc # # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in @@ -22,22 +22,55 @@ from mock import patch, Mock import requests +import pytest import conjur - @patch.object(requests, 'post') def test_authenticate(mock_post): - api = conjur.new_from_key("login", "api-key") - api.config.authn_url = "https://example.com" + api = conjur.new_from_key("alice", "api-key") + api.config.url = "http://possum.test" + mock_post.return_value = mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "token token token" + token = api.authenticate() + assert token == "token token token" + mock_post.assert_called_with("http://possum.test/authn/conjur/alice/authenticate", + "api-key", verify=api.config.verify) + +@patch.object(requests, 'post') +@patch.object(requests, 'get') +def test_authenticate_password(mock_get, mock_post): + mock_get.return_value = mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "api-key" + mock_post.return_value = mock_response = Mock() mock_response.status_code = 200 mock_response.text = "token token token" + + conjur.config.url = "http://possum.test" + api = conjur.new_from_password("alice", "secret password") token = api.authenticate() assert token == "token token token" - mock_post.assert_called_with("https://example.com/users/login/authenticate", + + mock_get.assert_called_with("http://possum.test/authn/conjur/login", + auth=("alice", "secret password"), + verify=api.config.verify) + mock_post.assert_called_with("http://possum.test/authn/conjur/alice/authenticate", "api-key", verify=api.config.verify) +@patch.object(requests, 'get') +def test_authenticate_wrong_password(mock_get): + mock_get.return_value = mock_response = Mock() + mock_response.status_code = 401 + + with pytest.raises(conjur.exceptions.ConjurException): + conjur.new_from_password("alice", "bad password") + + mock_get.assert_called_with("http://possum.test/authn/conjur/login", + auth=("alice", "bad password"), + verify=conjur.config.verify) @patch.object(requests, 'post') def test_authenticate_with_cached_token(mock_post): @@ -45,7 +78,6 @@ def test_authenticate_with_cached_token(mock_post): assert api.authenticate() == "token token" mock_post.assert_not_called() - def test_auth_header(): api = conjur.new_from_token("the token") expected = 'Token token="%s"' % (base64.b64encode("the token")) diff --git a/tests/conftest.py b/tests/conftest.py index 1fc7a53..f57fa47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,4 +2,4 @@ def pytest_runtest_setup(item): - config.appliance_url = 'https://example.com/api' + config.url = 'http://possum.test' diff --git a/tests/conjur_test.py b/tests/conjur_test.py index 46bb42c..fbbf8f9 100644 --- a/tests/conjur_test.py +++ b/tests/conjur_test.py @@ -38,6 +38,14 @@ def test_new_from_token(): assert api.config == config +def test_new_from_header(): + api = conjur.new_from_header("Token token=\"dGhlIHRva2Vu\"") + assert api.auth_header() == "Token token=\"dGhlIHRva2Vu\"" + assert api.api_key is None + assert api.login is None + assert api.config == config + + def test_new_with_config(): cfg = Config() api = conjur.new_from_key("login", "secret", cfg) diff --git a/tests/group_test.py b/tests/group_test.py deleted file mode 100644 index 5f9e1b4..0000000 --- a/tests/group_test.py +++ /dev/null @@ -1,52 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from mock import patch -import conjur - -api = conjur.new_from_key('foo', 'bar') - -group = api.group('v1/admins') - - -def test_group(): - assert group.role.kind == 'group' - assert group.role.identifier == 'v1/admins' - assert group.role.roleid == api.config.account + ':group:v1/admins' - - -@patch.object(group.role, 'grant_to') -def test_add_member(mock_grant_to): - member = api.user('foo') - group.add_member(member) - mock_grant_to.assert_called_with(member, False) - - -@patch.object(group.role, 'grant_to') -def test_add_member_admin(mock_grant_to): - member = api.role('something', 'else') - group.add_member(member, True) - mock_grant_to.assert_called_with(member, True) - - -@patch.object(group.role, 'revoke_from') -def test_remove_member(mock_revoke_from): - member = api.user('foo') - group.remove_member(member) - mock_revoke_from.assert_called_with(member) diff --git a/tests/pubkeys_test.py b/tests/pubkeys_test.py deleted file mode 100644 index faabd7f..0000000 --- a/tests/pubkeys_test.py +++ /dev/null @@ -1,74 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from mock import patch, Mock, call -import conjur - -api = conjur.new_from_key('fakeid', 'fakepass') - - -@patch.object(api, 'get') -def test_public_keys(mock_get): - response = "a b key1\na b key2" - mock_get.return_value = Mock(text=response) - assert api.public_keys('foo bar') == response - mock_get.assert_called_with( - '{0}/foo%20bar'.format(api.config.pubkeys_url) - ) - - -@patch.object(api, 'get') -def test_public_key(mock_get): - mock_get.return_value = Mock(text="a b c") - assert api.public_key('foo bar', 'keyname') == 'a b c' - mock_get.assert_called_with( - '{0}/foo%20bar/keyname'.format(api.config.pubkeys_url)) - - -@patch.object(api, 'get') -def test_public_key_names(mock_get): - response = "a b key1\na b key2" - mock_get.return_value = Mock(text=response) - assert list(api.public_key_names('foo bar')) == ['key1', 'key2'] - mock_get.assert_called_with( - '{0}/foo%20bar'.format(api.config.pubkeys_url) - ) - - -@patch.object(api, 'post') -def test_add_public_key(mock_post): - api.add_public_key('foo', 'a b c') - mock_post.assert_called_with(api.config.pubkeys_url + '/foo', data='a b c') - - -@patch.object(api, 'delete') -def test_remove_public_key(mock_del): - api.remove_public_key('foo', 'bar') - mock_del.assert_called_with(api.config.pubkeys_url + '/foo/bar') - - -@patch.object(api, 'delete') -@patch.object(api, 'get') -def test_remove_public_keys(mock_get, mock_del): - mock_get.return_value = Mock(text="a b key1\na b key2") - api.remove_public_keys('foo') - mock_del.assert_has_calls([ - call(api.config.pubkeys_url + '/foo/key1'), - call(api.config.pubkeys_url + '/foo/key2') - ]) diff --git a/tests/resource_test.py b/tests/resource_test.py index 038cb16..a69ae90 100644 --- a/tests/resource_test.py +++ b/tests/resource_test.py @@ -24,7 +24,7 @@ api = conjur.new_from_key('admin', 'secret') resource = api.resource('food', 'bacon') -bob = api.user('bob') +bob = api.role('user', 'bob') def test_resource_id(): @@ -37,19 +37,27 @@ def test_permitted_with_role(mock_get): assert resource.permitted('fry', bob) mock_get.assert_called_with( - 'https://example.com/api/authz/conjur/roles/user/bob', + 'http://possum.test/roles/conjur/user/bob', params={'privilege': 'fry', 'check': 'true', - 'resource_id': 'conjur:food:bacon'}, + 'resource': 'conjur:food:bacon'}, check_errors=False ) +def test_fq_resource(): + res = api.resource_qualified('foo:bar:baz') + assert res.url() == 'http://possum.test/resources/foo/bar/baz' + +def test_role_of_resource(): + res = api.resource_qualified('foo:bar:baz') + assert res.role().roleid == 'foo:bar:baz' + @patch.object(api, 'get') def test_permitted_self_role(mock_get): mock_get.return_value = Mock(status_code=204) assert resource.permitted('fry') mock_get.assert_called_with( - 'https://example.com/api/authz/conjur/resources/food/bacon', # noqa E501 (line too long) + 'http://possum.test/resources/conjur/food/bacon', params={'privilege': 'fry', 'check': 'true'}, check_errors=False ) @@ -79,3 +87,58 @@ def test_permitted_error_with_role(mock_get): mock_get.return_value = Mock(status_code=401) with pytest.raises(conjur.ConjurException): resource.permitted('fry', bob) + +@patch.object(api, 'get') +def test_get_secret_value(mock_get): + mock_get.return_value = resp = Mock() + resp.status_code = 200 + resp.text = 'teh value' + assert resource.secret() == 'teh value' + mock_get.assert_called_with( + '%s/secrets/conjur/food/bacon' % api.config.url, + check_errors = False + ) + +@patch.object(api, 'get') +def test_get_no_secret_value(mock_get): + mock_get.return_value = resp = Mock() + resp.status_code = 404 + assert resource.secret() == None + mock_get.assert_called_with( + '%s/secrets/conjur/food/bacon' % api.config.url, + check_errors = False + ) + +@patch.object(api, 'post') +def test_add_secret_value(mock_post): + mock_post.return_value = resp = Mock() + resp.status_code = 201 + resource.add_secret('boo') + mock_post.assert_called_with( + '%s/secrets/conjur/food/bacon' % api.config.url, + data='boo', + ) + + +@patch.object(api, 'get') +def test_resource_listing(mock_get): + resources = ['conjur:foo:bar', 'conjur:baz:bar'] + mock_get.return_value = resp = Mock( + json=lambda: [{"id": r} for r in resources], + status_code=200 + ) + resources_list = api.resources() + assert set(r.resourceid for r in resources_list) == set(resources) + mock_get.assert_called_with('http://possum.test/resources/conjur') + + +@patch.object(api, 'get') +def test_resource_listing_filtered(mock_get): + resources = ['conjur:foo:bar'] + mock_get.return_value = resp = Mock( + json=lambda: [{"id": r} for r in resources], + status_code=200 + ) + resources_list = api.resources(kind='foo') + assert set(r.resourceid for r in resources_list) == set(resources) + mock_get.assert_called_with('http://possum.test/resources/conjur/foo') diff --git a/tests/role_test.py b/tests/role_test.py index 0e1529f..8f2238f 100644 --- a/tests/role_test.py +++ b/tests/role_test.py @@ -23,7 +23,7 @@ config = Config() api = new_from_key('login', 'pass', config) config.account = 'the-account' -config.appliance_url = 'https://example.com/api' +config.url = 'http://possum.test' def test_roleid(): @@ -31,77 +31,46 @@ def test_roleid(): assert role.roleid == 'the-account:some-kind:the-id' -@patch.object(api, 'put') -def test_role_grant_to_without_admin(mock_put): - role = api.role('some-kind', 'the-id') - role.grant_to('some-other-role') - mock_put.assert_called_with( - '{0}/the-account/roles/some-kind/the-id?members&member={1}'.format( - config.authz_url, - 'some-other-role' - ), - data={} - ) - - -@patch.object(api, 'put') -def test_role_grant_to_with_admin_true(mock_put): - role = api.role('some-kind', 'the-id') - role.grant_to('some-other-role', True) - mock_put.assert_called_with( - '{0}/the-account/roles/some-kind/the-id?members&member={1}'.format( - config.authz_url, - 'some-other-role' - ), - data={'admin': 'true'} - ) - - -@patch.object(api, 'put') -def test_role_grant_to_with_admin_false(mock_put): - role = api.role('some-kind', 'the-id') - role.grant_to('some-other-role', False) - mock_put.assert_called_with( - '{0}/the-account/roles/some-kind/the-id?members&member={1}'.format( - config.authz_url, - 'some-other-role' - ), - data={'admin': 'false'} - ) +def test_role_qualified(): + role = api.role_qualified('foo:bar:baz') + assert role.url() == 'http://possum.test/roles/foo/bar/baz' -@patch.object(api, 'delete') -def test_role_revoke_from(mock_del): - role = api.role('some-kind', 'the-id') - role.revoke_from('some-other-role') - mock_del.assert_called_with( - '{0}/the-account/roles/some-kind/the-id?members&member={1}'.format( - config.authz_url, - 'some-other-role' - ) - ) +def test_resource_of_role(): + role = api.role_qualified('foo:bar:baz') + assert role.resource().resourceid == 'foo:bar:baz' +role_info = { + u'id': u'cucumber:group:everyone', + u'members': [ + { + u'member': u'cucumber:user:admin', + u'role': u'cucumber:group:everyone', + u'grantor': u'cucumber:group:everyone', + u'admin_option': True + }, { + u'member': u'cucumber:user:alice', + u'role': u'cucumber:group:everyone', + u'grantor': u'cucumber:group:everyone', + u'admin_option': False + } + ] +} @patch.object(api, 'get') def test_role_members(mock_get): - members = ['foo', 'bar'] - mock_get.return_value = Mock(json=lambda: members) + mock_get.return_value = Mock(json=lambda: role_info) role = api.role('blah', 'boo') - assert role.members() == members + assert role.members() == role_info['members'] mock_get.assert_called_with( - '{0}/the-account/roles/blah/boo?members'.format(config.authz_url) + 'http://possum.test/roles/the-account/blah/boo' ) - - -@patch.object(api, 'put') -def test_role_grant_to_user(mock_put): - role = api.role('somekind', 'admins') - user = api.user('somebody') - role.grant_to(user) - mock_put.assert_called_with( - '{0}/the-account/roles/somekind/admins?members&member={1}'.format( - config.authz_url, - 'the-account%3Auser%3Asomebody' - ), - data={} +@patch.object(api, 'get') +def test_public_keys(mock_get): + response = "a b key1\na b key2" + mock_get.return_value = Mock(text=response) + user = api.role('user', 'somebody') + assert user.public_keys() == response + mock_get.assert_called_with( + 'http://possum.test/public_keys/the-account/user/somebody' ) diff --git a/tests/user_test.py b/tests/user_test.py deleted file mode 100644 index 72cd63c..0000000 --- a/tests/user_test.py +++ /dev/null @@ -1,70 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -from mock import patch, Mock -import requests - -import conjur - - -@patch.object(requests, 'post') -def test_create_user(mock_post): - api = conjur.new_from_token('token') - mock_post.return_value = resp = Mock() - resp.status_code = 200 - resp.json = lambda: {'login': 'foo', 'api_key': 'apikey'} - - user_no_pass = api.create_user('foo') - assert user_no_pass.login == 'foo' - assert user_no_pass.api_key == 'apikey' - mock_post.assert_called_with( - '{0}/users'.format(api.config.core_url), - data={'login': 'foo'}, - headers={'Authorization': api.auth_header()}, - verify=api.config.verify - ) - - api.create_user('foo', 'bar') - mock_post.assert_called_with( - '{0}/users'.format(api.config.core_url), - data={'login': 'foo', 'password': 'bar'}, - headers={'Authorization': api.auth_header()}, - verify=api.config.verify - ) - - -@patch.object(requests, 'get') -def test_user(mock_get): - api = conjur.new_from_token('token') - mock_get.return_value = Mock(status_code=200, json=lambda: {'foo': 'bar'}) - user = api.user('login') - assert user.foo == 'bar' - mock_get.assert_called_with( - '{0}/users/login'.format(api.config.core_url), - headers={'Authorization': api.auth_header()}, - verify=api.config.verify - ) - - -def test_user_role(): - user = conjur.new_from_key('foo', 'bar').user('someone') - role = user.role - assert role.kind == 'user' - assert role.identifier == 'someone' diff --git a/tests/variable_test.py b/tests/variable_test.py deleted file mode 100644 index 50abd30..0000000 --- a/tests/variable_test.py +++ /dev/null @@ -1,71 +0,0 @@ -# -# Copyright (C) 2014 Conjur Inc -# -# Permission is hereby granted, free of charge, to any person obtaining a copy of -# this software and associated documentation files (the "Software"), to deal in -# the Software without restriction, including without limitation the rights to -# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -# the Software, and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -from mock import patch, Mock -import requests - -import conjur - - -@patch.object(requests, 'post') -def test_create_variable(mock_post): - mock_post.return_value = mock_response = Mock() - mock_response.status_code = 201 - mock_response.json = lambda: {'id': 'foobar'} # No attribute support now - api = conjur.new_from_token('token') - v = api.create_variable(mime_type='mimey', kind='something') - assert v.id == 'foobar' - mock_post.assert_called_with( - '%s/variables' % api.config.core_url, - data={'mime_type': 'mimey', 'kind': 'something'}, - headers={'Authorization': api.auth_header()}, - verify=api.config.verify - ) - - -@patch.object(requests, 'get') -def test_get_variable_value(mock_get): - mock_get.return_value = resp = Mock() - resp.status_code = 200 - resp.text = 'teh value' - api = conjur.new_from_token('token') - v = api.variable('my-id') - assert v.value() == 'teh value' - mock_get.assert_called_with( - '%s/variables/my-id/value' % api.config.core_url, - headers={'Authorization': api.auth_header()}, - verify=api.config.verify - ) - - -@patch.object(requests, 'post') -def test_add_variable_value(mock_post): - mock_post.return_value = resp = Mock() - resp.status_code = 201 - api = conjur.new_from_token('token') - v = api.variable('var') - v.add_value('boo') - mock_post.assert_called_with( - '%s/variables/var/values' % api.config.core_url, - headers={'Authorization': api.auth_header()}, - data={'value': 'boo'}, - verify=api.config.verify - )