├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── ci └── test.sh ├── groupy ├── __init__.py ├── client.py ├── collations.py ├── exc.py ├── resources.py ├── servers.py └── version.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements-dev2.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── fixtures.py ├── test_checkpoint.py ├── test_request.py └── test_resources.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.6 5 | cache: 6 | directories: 7 | - $HOME/.cache/pip 8 | 9 | # Only run additional style checks with Python 3 dependencies on Python 3. 10 | # (These conditionals are written weirdly backwards so that the command 11 | # line evaluates to true.) 12 | 13 | install: 14 | - pip install -r requirements.txt 15 | - test "$TRAVIS_PYTHON_VERSION" != 2.7 || pip install -r requirements-dev2.txt 16 | - test "$TRAVIS_PYTHON_VERSION" != 3.6 || pip install -r requirements-dev.txt 17 | script: ci/test.sh 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2016 Dropbox, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include requirements.txt 4 | include requirements-dev.txt 5 | include requirements-dev2.txt 6 | recursive-include tests * 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | groupy 3 | ====== 4 | 5 | Deprecation 6 | ----------- 7 | 8 | The Grouper/Groupy projects have been deprecated. You may continue to use and maintain forks of the project, but the Dropbox team will no longer contribute to this repository. 9 | 10 | Description 11 | ----------- 12 | 13 | Python client library for interfacing with the Grouper API server. 14 | 15 | Quickstart 16 | ---------- 17 | 18 | Super basic... 19 | 20 | .. code:: python 21 | 22 | from groupy.client import Groupy 23 | grclient = Groupy('127.0.0.1:8990') 24 | for user in grclient.users: 25 | print user 26 | for permission in grclient.users.get('zorkian'): 27 | print permission 28 | 29 | Installation 30 | ------------ 31 | 32 | To pull the latest version from PyPI: 33 | 34 | .. code:: bash 35 | 36 | pip install groupy 37 | -------------------------------------------------------------------------------- /ci/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | # Tests run under Python 2. 6 | if [[ "$TRAVIS_PYTHON_VERSION" == 2* ]]; then 7 | pytest -x -v 8 | flake8 9 | fi 10 | 11 | # Tests run under Python 3. 12 | if [[ "$TRAVIS_PYTHON_VERSION" == 3* ]]; then 13 | pytest -x -v 14 | mypy . 15 | black --check . 16 | flake8 17 | fi 18 | -------------------------------------------------------------------------------- /groupy/__init__.py: -------------------------------------------------------------------------------- 1 | from groupy.version import __version__ # noqa: F401 2 | -------------------------------------------------------------------------------- /groupy/client.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import json 3 | import logging 4 | import socket 5 | from threading import Lock 6 | from typing import NamedTuple, TYPE_CHECKING 7 | 8 | from clowncar.backends import Backends 9 | from tornado.httpclient import HTTPClient, HTTPError, HTTPRequest 10 | 11 | from groupy import exc 12 | from groupy.collations import Groups, Permissions, ServiceAccounts, Users 13 | 14 | try: 15 | from urllib.parse import urlencode 16 | except ImportError: 17 | from urllib import urlencode # type: ignore 18 | 19 | if TYPE_CHECKING: 20 | from clowncar.server import Server 21 | from typing import Any, Dict, List, Optional 22 | 23 | Checkpoint = NamedTuple("Checkpoint", [("checkpoint", int), ("checkpoint_time", float)]) 24 | 25 | 26 | def _checkpoint_is_greater(a, b): 27 | # type: (Checkpoint, Checkpoint) -> bool 28 | """Ensure elements of checkpoint 'a' are all greater than or equal to those in 29 | checkpoint 'b'.""" 30 | return a.checkpoint >= b.checkpoint and a.checkpoint_time >= b.checkpoint_time 31 | 32 | 33 | class Groupy(object): 34 | def __init__( 35 | self, 36 | servers, # type: List[Server] 37 | partition_key=None, # type: Optional[str] 38 | timeout=3, # type: int 39 | allow_time_travel=False, # type: bool 40 | checkpoint=0, # type: int 41 | checkpoint_time=0, # type: float 42 | mark_bad_timeout=60, # type: int 43 | max_backend_tries=5, # type: int 44 | use_ssl=False, # type: bool 45 | request_kwargs=None, # type: Optional[Dict[str, Any]] 46 | ): 47 | # type: (...) -> None 48 | """ 49 | The grouper client. 50 | 51 | Args: 52 | servers (list of clowncar.server.Server): available API servers 53 | partition_key (str): key to use for picking a server, None defaults 54 | to hostname 55 | timeout (int): connection and request sent to tornado's HTTPClient 56 | allow_time_travel (bool): allow checkpoint[_time] to go backwards 57 | in subsequent queries 58 | checkpoint (int): starting checkpoint 59 | checkpoint_time (float): starting checkpoint unix epoch time 60 | mark_bad_timeout (int): time in seconds to not use servers that 61 | have been marked as dead 62 | max_backend_tries (int): number of backend servers to try before 63 | giving up and raising a BackendConnectionError 64 | use_ssl (int): whether to connect to backend servers using https 65 | rather than http. 66 | TODO(): use_ssl should default to True. It currently defaults 67 | to False solely to preserve backwards compatibility. 68 | request_kwargs (Dict of kwargs): additional kwargs to be passed to 69 | the Tornado HTTPRequest object in every _fetch call. Individual 70 | kwargs can be overwritten if specified in a given _fetch call. 71 | """ 72 | 73 | self._lock = Lock() 74 | self.timeout = timeout 75 | self.backends = Backends(servers, partition_key) 76 | 77 | self.checkpoint = Checkpoint(checkpoint, checkpoint_time) 78 | 79 | self.allow_time_travel = allow_time_travel 80 | self.mark_bad_timeout = mark_bad_timeout 81 | self.max_backend_tries = max_backend_tries 82 | self.use_ssl = use_ssl 83 | self.request_kwargs = request_kwargs 84 | 85 | self.users = Users(self, "users") 86 | self.groups = Groups(self, "groups") 87 | self.permissions = Permissions(self, "permissions") 88 | self.service_accounts = ServiceAccounts(self, "service_accounts") 89 | 90 | def _try_fetch(self, path, **kwargs): 91 | # type: (str, **Any) -> Dict[str, Any] 92 | last_failed_server = None 93 | for idx in range(self.max_backend_tries): 94 | try: 95 | return self._fetch(path, **kwargs) 96 | except (exc.BackendConnectionError, exc.BackendIntegrityError) as err: 97 | logging.warning("Marking server {} as dead.".format(err.server.hostname)) 98 | self.backends.mark_dead(err.server, self.mark_bad_timeout) 99 | last_failed_server = err.server 100 | raise exc.BackendConnectionError( 101 | "Tried {} servers, all failed.".format(self.max_backend_tries), last_failed_server 102 | ) 103 | 104 | def _fetch(self, path, **kwargs): 105 | # type: (str, **Any) -> Dict[str, Any] 106 | http_client = HTTPClient() 107 | server = self.backends.server 108 | protocol = "https" if self.use_ssl else "http" 109 | if self.request_kwargs: 110 | merged_kwargs = self.request_kwargs.copy() 111 | merged_kwargs.update(kwargs) 112 | kwargs = merged_kwargs 113 | url = HTTPRequest( 114 | "{}://{}:{}{}".format(protocol, server.hostname, server.port, path), 115 | connect_timeout=self.timeout, 116 | request_timeout=self.timeout, 117 | **kwargs 118 | ) 119 | try: 120 | out = json.loads(http_client.fetch(url).body) 121 | except HTTPError as err: 122 | message = err.message or "" 123 | if err.code == 599: 124 | raise exc.BackendConnectionError(message, server) 125 | if err.response: 126 | try: 127 | out = json.loads(err.response.body) 128 | if "status" not in out: 129 | raise exc.BackendIntegrityError(message, server) 130 | except (ValueError, TypeError): 131 | raise exc.BackendIntegrityError(message, server) 132 | else: 133 | raise exc.BackendIntegrityError(message, server) 134 | except socket.error as err: 135 | if err.errno == errno.ECONNREFUSED: 136 | raise exc.BackendConnectionError("socket error (Connection Refused)", server) 137 | raise 138 | 139 | with self._lock: 140 | new_checkpoint = Checkpoint(out["checkpoint"], out["checkpoint_time"]) 141 | old_checkpoint = self.checkpoint 142 | if ( 143 | not _checkpoint_is_greater(new_checkpoint, old_checkpoint) 144 | and not self.allow_time_travel 145 | ): 146 | raise exc.TimeTravelNotAllowed( 147 | "Received checkpoint of {} when previously {}".format( 148 | new_checkpoint, old_checkpoint 149 | ), 150 | server, 151 | ) 152 | self.checkpoint = new_checkpoint 153 | 154 | return out 155 | 156 | def authenticate(self, token): 157 | # type: (str) -> Dict[str, Any] 158 | return self._try_fetch("/token/validate", method="POST", body=urlencode({"token": token})) 159 | -------------------------------------------------------------------------------- /groupy/collations.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from groupy import exc 4 | from groupy.resources import Group, Permission, ServiceAccount, User 5 | 6 | if TYPE_CHECKING: 7 | from typing import Any, Dict, Iterator, Optional 8 | from groupy.client import Groupy 9 | 10 | 11 | class Collection(object): 12 | def __init__(self, client, name): 13 | # type: (Groupy, str) -> None 14 | self.client = client 15 | self.name = name 16 | 17 | def _get(self, resource=None): 18 | # type: (Optional[str]) -> Dict[str, Any] 19 | path = "/{}".format(self.name) 20 | if resource: 21 | path += "/{}".format(resource) 22 | response = self.client._try_fetch(path) 23 | self._check_error(response) 24 | return response 25 | 26 | @staticmethod 27 | def _check_error(response): 28 | # type: (Dict[str, Any]) -> None 29 | if response["status"] == "ok": 30 | return 31 | 32 | combined = [] 33 | for error in response["errors"]: 34 | code, message = error["code"], error["message"] 35 | if code == 404: 36 | raise exc.ResourceNotFound(message) 37 | combined.append("{}: {}".format(code, message)) 38 | 39 | raise exc.ResourceError(", ".join(combined)) 40 | 41 | def __iter__(self): 42 | # type: () -> Iterator[object] 43 | response = self._get() 44 | for resource in response["data"][self.name]: 45 | yield resource 46 | 47 | 48 | class Groups(Collection): 49 | def get(self, resource): 50 | # type: (str) -> Group 51 | return Group.from_payload(self._get(resource)) 52 | 53 | 54 | class Users(Collection): 55 | def get(self, resource): 56 | # type: (str) -> User 57 | return User.from_payload(self._get(resource)) 58 | 59 | 60 | class ServiceAccounts(Collection): 61 | def get(self, resource): 62 | # type: (str) -> User 63 | """Service accounts do not (yet) have their own meaningful class.""" 64 | return ServiceAccount.from_payload(self._get(resource)) 65 | 66 | 67 | class Permissions(Collection): 68 | def get(self, resource): 69 | # type: (str) -> Permission 70 | return Permission.from_payload(self._get(resource)) 71 | -------------------------------------------------------------------------------- /groupy/exc.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from clowncar.server import Server 5 | 6 | 7 | class Error(Exception): 8 | pass 9 | 10 | 11 | class BackendError(Error): 12 | def __init__(self, message, server): 13 | # type: (str, Server) -> None 14 | self.message = message 15 | self.server = server 16 | 17 | def __str__(self): 18 | # type: () -> str 19 | return "({}:{}) - {}".format(self.server.hostname, self.server.port, self.message) 20 | 21 | 22 | class BackendConnectionError(BackendError): 23 | pass 24 | 25 | 26 | class BackendIntegrityError(BackendError): 27 | pass 28 | 29 | 30 | class TimeTravelNotAllowed(BackendError): 31 | pass 32 | 33 | 34 | class BackendMaxDriftError(BackendError): 35 | pass 36 | 37 | 38 | class ResourceError(Error): 39 | pass 40 | 41 | 42 | class ResourceNotFound(ResourceError): 43 | pass 44 | -------------------------------------------------------------------------------- /groupy/resources.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from six import iteritems, string_types 4 | 5 | if TYPE_CHECKING: 6 | from typing import Any, Dict, List, Optional, Union 7 | 8 | 9 | class ResourceDict(dict): 10 | def __call__(self, direct=False, roles=None): 11 | # type: (bool, Union[str, List[str]]) -> Dict[str, Any] 12 | if isinstance(roles, string_types): 13 | roles = [roles] 14 | new_dict = {} 15 | for key, value in iteritems(self): 16 | if direct and value.get("distance", 0) != 1: 17 | continue 18 | if roles and value.get("rolename") not in roles: 19 | continue 20 | new_dict[key] = value 21 | return new_dict 22 | 23 | 24 | class Group(object): 25 | def __init__( 26 | self, 27 | groups, # type: Dict[str, Dict[str, Any]] 28 | users, # type: Dict[str, Dict[str, Any]] 29 | subgroups, # type: Dict[str, Dict[str, Any]] 30 | permissions, # type: List[Dict[str, Any]] 31 | audited, # type: bool 32 | contacts, # type: Dict[str, str] 33 | ): 34 | # type: (...) -> None 35 | self.groups = ResourceDict(groups) 36 | self.users = ResourceDict(users) 37 | self.subgroups = ResourceDict(subgroups) 38 | self.permissions = [ 39 | MappedPermission.from_payload(permission) for permission in permissions 40 | ] 41 | self.audited = audited 42 | self.contacts = contacts 43 | 44 | @classmethod 45 | def from_payload(cls, payload): 46 | # type: (Dict[str, Any]) -> Group 47 | return cls( 48 | payload["data"]["groups"], 49 | payload["data"]["users"], 50 | payload["data"]["subgroups"], 51 | payload["data"]["permissions"], 52 | # New values may not exist in the JSON objects, so we need to be 53 | # careful. 54 | payload["data"].get("audited", False), 55 | # TODO(lfaraone): Figure out why we don't always return 'group' 56 | payload["data"].get("group", dict()).get("contacts", dict()), 57 | ) 58 | 59 | 60 | class User(object): 61 | def __init__( 62 | self, 63 | groups, # type: Dict[str, Dict[str, Any]] 64 | public_keys, # type: List[Dict[str, Any]] 65 | permissions, # type: List[Dict[str, Any]] 66 | metadata, # type: List[Dict[str, str]] 67 | enabled, # type: bool 68 | passwords, # type: List[Dict[str, str]] 69 | service_account, # type: Optional[Dict[str, str]] 70 | ): 71 | # type: (...) -> None 72 | self.groups = ResourceDict(groups) 73 | self.passwords = passwords 74 | self.public_keys = public_keys 75 | self.enabled = enabled 76 | self.service_account = service_account 77 | self.permissions = [ 78 | MappedPermission.from_payload(permission) for permission in permissions 79 | ] 80 | self.metadata = {md["data_key"]: UserMetadata.from_payload(md) for md in metadata} 81 | 82 | @classmethod 83 | def from_payload(cls, payload): 84 | # type: (Dict[str, Any]) -> User 85 | return cls( 86 | payload["data"]["groups"], 87 | payload["data"]["user"]["public_keys"], 88 | payload["data"]["permissions"], 89 | payload["data"]["user"]["metadata"], 90 | payload["data"]["user"]["enabled"], 91 | # New values may not exist in the JSON objects, so we need to be careful. 92 | payload["data"]["user"].get("passwords", []), 93 | # Optional field only present for service accounts. 94 | # 95 | # TODO(rra): ServiceAccount objects should lift these up to top-level properties and 96 | # User objects should not have this, and we should return a ServiceAccount when 97 | # retrieving a User with this set. 98 | payload["data"]["user"].get("service_account"), 99 | ) 100 | 101 | 102 | class ServiceAccount(User): 103 | pass 104 | 105 | 106 | class Permission(object): 107 | def __init__(self, groups, audited): 108 | # type: (Dict[str, Dict[str, Any]], bool) -> None 109 | self.groups = { 110 | groupname: Group.from_payload({"data": groups[groupname]}) for groupname in groups 111 | } 112 | self.audited = audited 113 | 114 | @classmethod 115 | def from_payload(cls, payload): 116 | # type: (Dict[str, Any]) -> Permission 117 | return cls(payload["data"]["groups"], payload["data"]["audited"]) 118 | 119 | 120 | class MappedPermission(object): 121 | def __init__(self, permission, argument, granted_on, distance, path, audited): 122 | # type: (str, str, float, Optional[int], Optional[List[str]], Optional[bool]) -> None 123 | self.permission = permission 124 | self.argument = argument 125 | self.granted_on = granted_on 126 | self.distance = distance 127 | self.path = path 128 | self.audited = audited 129 | 130 | @classmethod 131 | def from_payload(cls, payload): 132 | # type: (Dict[str, Any]) -> MappedPermission 133 | return cls( 134 | payload["permission"], 135 | payload["argument"], 136 | payload["granted_on"], 137 | payload.get("distance"), 138 | payload.get("path"), 139 | payload.get("audited"), 140 | ) 141 | 142 | 143 | class UserMetadata(object): 144 | def __init__(self, key, value, last_modified): 145 | # type: (str, str, str) -> None 146 | self.key = key 147 | self.value = value 148 | self.last_modified = last_modified 149 | 150 | @classmethod 151 | def from_payload(cls, payload): 152 | # type: (Dict[str, Any]) -> UserMetadata 153 | return cls(payload["data_key"], payload["data_value"], payload["last_modified"]) 154 | -------------------------------------------------------------------------------- /groupy/servers.py: -------------------------------------------------------------------------------- 1 | import time 2 | from threading import Lock 3 | from typing import TYPE_CHECKING 4 | 5 | from clowncar.server import Server 6 | 7 | if TYPE_CHECKING: 8 | from typing import Set 9 | 10 | 11 | class ServersFileLoader(object): 12 | def __init__(self, filename, cache_timeout=300): 13 | # type: (str, int) -> None 14 | self.filename = filename 15 | self._lock = Lock() 16 | self.cache_timeout = cache_timeout 17 | self._servers = set() # type: Set[Server] 18 | self._last_cache = 0.0 19 | 20 | def _load_servers(self): 21 | # type: () -> None 22 | servers = set() 23 | 24 | with open(self.filename) as servers_file: 25 | for line in servers_file: 26 | line = line.split("#", 1)[0].strip() 27 | if not line: 28 | continue 29 | 30 | if line.count(":") != 1: 31 | continue 32 | 33 | hostname, port_str = line.split(":") 34 | try: 35 | port = int(port_str) 36 | except ValueError: 37 | continue 38 | 39 | servers.add(Server(hostname, port)) 40 | 41 | self._servers = servers 42 | 43 | @property 44 | def servers(self): 45 | # type: () -> Set[Server] 46 | with self._lock: 47 | now = time.time() 48 | if self._last_cache + self.cache_timeout > now: 49 | return self._servers 50 | 51 | self._last_cache = now 52 | self._load_servers() 53 | return self._servers 54 | 55 | def __call__(self): 56 | # type: () -> Set[Server] 57 | return self.servers 58 | -------------------------------------------------------------------------------- /groupy/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.6" 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 99 3 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black>=19.3b0 2 | flake8>=3.7.7 3 | flake8-import-order>=0.18.1 4 | mypy>=0.701 5 | pytest>=2.6 6 | pytest-runner 7 | mock>=2.0 8 | types-mock>=4.0.1 9 | types-six>=1.16.1 10 | -------------------------------------------------------------------------------- /requirements-dev2.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.7.7 2 | flake8-import-order>=0.18.1 3 | pytest>=2.6 4 | pytest-runner 5 | mock>=2.0 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | clowncar>=0.2.0 2 | six>=1.12.0 3 | tornado>=5.1.1 4 | typing>=3.6.4 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test = pytest 3 | 4 | [flake8] 5 | max-line-length = 99 6 | import-order-style = smarkets 7 | application-import-names = groupy, tests 8 | 9 | # E203: whitespace before :, black says to disable 10 | # W503: line break after binary operator, black says to disable 11 | ignore = E203, W503 12 | 13 | [mypy] 14 | disallow_untyped_defs = True 15 | disallow_incomplete_defs = True 16 | ignore_missing_imports = True 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | 5 | from setuptools import setup 6 | 7 | try: 8 | from typing import List 9 | except Exception: 10 | pass 11 | 12 | # Define __version__. This is equivalent to execfile but works in Python 3. 13 | with open("groupy/version.py", "r") as version: 14 | code = compile(version.read(), "groupy/version.py", "exec") 15 | exec(code) 16 | 17 | # Installation requirements. 18 | with open("requirements.txt") as requirements: 19 | requires = requirements.read().splitlines() 20 | 21 | # Test suite requirements. 22 | with open("requirements-dev2.txt") as requirements: 23 | test_requires = requirements.read().splitlines() 24 | 25 | # Add pytest-runner to setup_requires if running setup with the test argument. 26 | setup_requires = [] # type: List[str] 27 | if "test" in sys.argv: 28 | setup_requires += ["pytest-runner"] 29 | 30 | kwargs = { 31 | "name": "groupy", 32 | "version": __version__, # type: ignore # noqa: F821 33 | "packages": ["groupy"], 34 | "description": "Python client library for Grouper", 35 | "long_description": open("README.rst").read(), 36 | "author": "Gary M. Josack, Mark Smith, Herbert Ho, Luke Faraone, Russ Allbery", 37 | "license": "Apache-2.0", 38 | "install_requires": requires, 39 | "setup_requires": setup_requires, 40 | "tests_require": test_requires, 41 | "url": "https://github.com/dropbox/groupy", 42 | "classifiers": [ 43 | "Programming Language :: Python :: 2", 44 | "Programming Language :: Python :: 3", 45 | "Topic :: Software Development", 46 | "Topic :: Software Development :: Libraries", 47 | "Topic :: Software Development :: Libraries :: Python Modules", 48 | ], 49 | } 50 | 51 | setup(**kwargs) 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/groupy/e9f61ea64a6ff73cb5a0390b466e9e55eba3dd22/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | 5 | if TYPE_CHECKING: 6 | from typing import Any, Dict, Text 7 | 8 | 9 | @pytest.fixture 10 | def service_account_response(request): 11 | # type: (str) -> Dict[Text, Any] 12 | return { 13 | u"checkpoint": 10, 14 | u"checkpoint_time": 1000, 15 | u"data": { 16 | u"groups": {}, 17 | u"permissions": [ 18 | {u"argument": u"shell", u"granted_on": 1452796706.894347, u"permission": u"sudo"} 19 | ], 20 | u"user": { 21 | u"enabled": True, 22 | u"metadata": [], 23 | u"name": u"service@a.co", 24 | u"public_keys": [], 25 | u"role_user": False, 26 | u"service_account": { 27 | u"description": u"Some service account", 28 | u"machine_set": u"shell", 29 | u"owner": u"security-team", 30 | }, 31 | }, 32 | }, 33 | u"status": u"ok", 34 | } 35 | 36 | 37 | @pytest.fixture 38 | def permission_response(request): 39 | # type: (str) -> Dict[Text, Any] 40 | return { 41 | u"checkpoint": 3, 42 | u"checkpoint_time": 1605842894, 43 | u"data": { 44 | u"permission": {u"name": "grouper.audit.security"}, 45 | u"groups": {}, 46 | u"service_accounts": {}, 47 | u"audited": False, 48 | }, 49 | u"status": u"ok", 50 | } 51 | 52 | 53 | @pytest.fixture 54 | def user_response(request): 55 | # type: (str) -> Dict[Text, Any] 56 | return { 57 | u"checkpoint": 10, 58 | u"checkpoint_time": 1000, 59 | u"data": { 60 | u"groups": { 61 | u"all-teams": { 62 | u"distance": 3, 63 | u"name": u"all-teams", 64 | u"path": [u"oliver@a.co", u"security-team", u"team-infra", u"all-teams"], 65 | u"role": 0, 66 | u"rolename": u"member", 67 | }, 68 | u"sad-team": { 69 | u"distance": 1, 70 | u"name": u"sad-team", 71 | u"path": [u"oliver@a.co", u"sad-team"], 72 | u"role": 0, 73 | u"rolename": u"member", 74 | }, 75 | u"security-team": { 76 | u"distance": 1, 77 | u"name": u"security-team", 78 | u"path": [u"oliver@a.co", u"security-team"], 79 | u"role": 2, 80 | u"rolename": u"owner", 81 | }, 82 | u"team-infra": { 83 | u"distance": 2, 84 | u"name": u"team-infra", 85 | u"path": [u"oliver@a.co", u"security-team", u"team-infra"], 86 | u"role": 0, 87 | u"rolename": u"member", 88 | }, 89 | }, 90 | u"permissions": [ 91 | { 92 | u"argument": u"shell", 93 | u"distance": 2, 94 | u"granted_on": 1452796706.894347, 95 | u"path": [u"oliver@a.co", u"security-team", u"team-infra"], 96 | u"permission": u"sudo", 97 | } 98 | ], 99 | u"user": { 100 | u"enabled": True, 101 | u"metadata": [], 102 | u"name": u"oliver@a.co", 103 | u"public_keys": [], 104 | u"role_user": False, 105 | }, 106 | }, 107 | u"status": u"ok", 108 | } 109 | -------------------------------------------------------------------------------- /tests/test_checkpoint.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING 3 | 4 | import pytest 5 | from mock import Mock, patch 6 | 7 | from groupy.client import Groupy, HTTPClient 8 | from groupy.exc import TimeTravelNotAllowed 9 | from tests.fixtures import user_response # noqa: F401 10 | 11 | if TYPE_CHECKING: 12 | from typing import Any, Dict, Text 13 | 14 | 15 | def test_checkpoint(user_response): # noqa: F811 16 | # type: (Dict[Text, Any]) -> None 17 | res1 = Mock() 18 | res1.body = json.dumps( 19 | { 20 | u"checkpoint": 10, 21 | u"checkpoint_time": 1000, 22 | u"data": user_response["data"], 23 | u"status": u"ok", 24 | } 25 | ) 26 | res2 = Mock() 27 | res2.body = json.dumps( 28 | { 29 | u"checkpoint": 11, 30 | u"checkpoint_time": 1001, 31 | u"data": user_response["data"], 32 | u"status": u"ok", 33 | } 34 | ) 35 | 36 | # sunny day 37 | mock_fetch = Mock() 38 | mock_fetch.side_effect = [res1, res2] 39 | with patch.object(HTTPClient, "fetch", mock_fetch): 40 | client = Groupy(["localhost:8000"]) 41 | client.users.get("oliver@a.co") 42 | client.users.get("oliver@a.co") 43 | 44 | # time travel not allowed 45 | mock_fetch.side_effect = [res2, res1] 46 | with patch.object(HTTPClient, "fetch", mock_fetch), pytest.raises(TimeTravelNotAllowed): 47 | client = Groupy(["localhost:8000"]) 48 | client.users.get("oliver@a.co") 49 | client.users.get("oliver@a.co") 50 | 51 | # time travel allowed 52 | mock_fetch.side_effect = [res2, res1] 53 | with patch.object(HTTPClient, "fetch", mock_fetch): 54 | client = Groupy(["localhost:8000"], allow_time_travel=True) 55 | client.users.get("oliver@a.co") 56 | client.users.get("oliver@a.co") 57 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING 3 | 4 | import pytest 5 | from mock import Mock, patch 6 | from tornado.httpclient import HTTPRequest 7 | 8 | from groupy.client import Groupy, HTTPClient 9 | from tests.fixtures import service_account_response # noqa: F401 10 | 11 | if TYPE_CHECKING: 12 | from typing import Any, Dict, Text 13 | 14 | 15 | def test_request_kwargs(service_account_response): # noqa: F811 16 | # type: (Dict[Text, Any]) -> None 17 | mock_fetch = Mock() 18 | resp = Mock() 19 | resp.body = json.dumps(service_account_response) 20 | 21 | def check_request_obj(request): 22 | # type: (HTTPRequest) -> Mock 23 | assert request.user_agent == "a string" 24 | assert request.follow_redirects is False 25 | return resp 26 | 27 | mock_fetch.side_effect = check_request_obj 28 | 29 | with patch.object(HTTPClient, "fetch", mock_fetch): 30 | # Confirm basic HTTPRequest construction with kwargs works 31 | http_req_kwargs = {"user_agent": "a string", "follow_redirects": False} 32 | client = Groupy(["localhost:8000"], request_kwargs=http_req_kwargs) 33 | client.users.get("service@a.co") 34 | assert mock_fetch.call_count == 1 35 | 36 | # Confirm overwriting kwargs in individual _fetch calls works 37 | with pytest.raises(AssertionError): 38 | client._fetch("/some/path", user_agent="a different string") 39 | assert mock_fetch.call_count == 2 40 | -------------------------------------------------------------------------------- /tests/test_resources.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING 3 | 4 | from mock import Mock, patch 5 | 6 | from groupy.client import Groupy, HTTPClient 7 | from tests.fixtures import permission_response # noqa: F401 8 | from tests.fixtures import service_account_response # noqa: F401 9 | 10 | if TYPE_CHECKING: 11 | from typing import Any, Dict, Text 12 | 13 | 14 | def test_service_account(service_account_response): # noqa: F811 15 | # type: (Dict[Text, Any]) -> None 16 | res = Mock() 17 | res.body = json.dumps(service_account_response) 18 | mock_fetch = Mock() 19 | mock_fetch.side_effect = [res] 20 | with patch.object(HTTPClient, "fetch", mock_fetch): 21 | client = Groupy(["localhost:8000"]) 22 | service = client.users.get("service@a.co") 23 | assert service.enabled 24 | assert service.groups == {} 25 | assert service.passwords == [] 26 | 27 | assert len(service.permissions) == 1 28 | assert service.permissions[0].permission == "sudo" 29 | assert service.permissions[0].argument == "shell" 30 | assert service.permissions[0].granted_on == 1452796706.894347 31 | assert service.permissions[0].distance is None 32 | assert service.permissions[0].path is None 33 | 34 | expected = service_account_response["data"]["user"]["service_account"] 35 | assert service.service_account == expected 36 | 37 | 38 | def test_permission(permission_response): # noqa: F811 39 | # type: (Dict[Text, Any]) -> None 40 | res = Mock() 41 | res.body = json.dumps(permission_response) 42 | mock_fetch = Mock() 43 | mock_fetch.side_effect = [res] 44 | with patch.object(HTTPClient, "fetch", mock_fetch): 45 | client = Groupy(["localhost:8000"]) 46 | permission = client.permissions.get("grouper.audit.security") 47 | assert permission.groups == {} 48 | assert isinstance(permission.audited, bool) 49 | assert permission.audited is False 50 | --------------------------------------------------------------------------------