├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── FEATURE_REQUEST_TEMPLATE.md │ └── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE │ └── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── lint.yml │ ├── release.yml │ └── run_tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── conftest.py ├── delinea ├── __init__.py └── secrets │ ├── __init__.py │ └── server.py ├── example.py ├── pyproject.toml ├── renovate.json ├── requirements.txt ├── tests └── test_server.py └── tox.ini /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | At Delinea we value transparency and clarity in our communications, respect, and integrity in our relationships. 2 | 3 | Our Code of Conduct outlines our expectations from the contributors and maintainers of this repository. 4 | You can [read the full Code of Conduct here](https://github.com/DelineaXPM/.github/blob/main/CODE_OF_CONDUCT.md). 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: If you have a feature or enhancement request 4 | --- 5 | 6 | ## Feature / Enhancement proposed 7 | 8 | _What capability would you like to add? Is it something you currently you cannot do? Is this related to an issue/problem?_ 9 | 10 | ## Workarounds 11 | 12 | Can you achieve the same result doing it in an alternative way? 13 | 14 | ## Has the feature been requested before? 15 | 16 | _If yes, Please provide a link to relevant issues and PRs._ 17 | 18 | ## If the feature request is approved, would you be willing to submit a PR? 19 | 20 | _(Help can be provided if you need assistance submitting a PR)_ 21 | 22 | [] Yes [] No 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: For reporting bugs and other general issues. 4 | --- 5 | 6 | **Description of the issue** 7 | 8 | Describe your issue here. 9 | 10 | **Expected behavior** 11 | 12 | Tell us what _should_ happen 13 | 14 | **Actual behavior** 15 | 16 | Tell us what _actually_ happens 17 | 18 | **Your environment** 19 | 20 | Tell us more about your environment; such as, What OS are you running? What version of _pluginName_ are you using? Etc. 21 | 22 | **Steps to reproduce** 23 | 24 | Tell us how to reproduce this issue. Please include code examples as necessary. 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request 3 | about: If you have a pull request to fix a bug or add a feature 4 | --- 5 | 6 | ## Pull request checklist 7 | 8 | Please check if your PR fulfills the following requirements: 9 | 10 | - [ ] You have read the contributing guide 11 | - [ ] Tests for the changes have been added 12 | - [ ] The documentation has been reviewed and updated as needed 13 | 14 | ## What is the current behavior? 15 | 16 | _Please describe the current behavior that you are modifying, and link its a relevant issue_ 17 | 18 | Issue Number: _Add the issue number this PR address here._ 19 | 20 | ## What is the new behavior? 21 | 22 | - 23 | - 24 | - 25 | 26 | ## Does this introduce a breaking change? 27 | 28 | - [ ] Yes 29 | - [ ] No 30 | 31 | **If yes, please describe...** 32 | 33 | ## Other relevant information 34 | 35 | _e.g. does this PR require another PR to be merged first?_ -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | name: Run black linter 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 19 | - name: Set up Python 20 | uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4 21 | - name: Install Python dependencies 22 | run: pip install black 23 | - name: Run black 24 | uses: wearerequired/lint-action@548d8a7c4b04d3553d32ed5b6e91eb171e10e7bb # v2 25 | with: 26 | black: true 27 | auto_fix: true -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4 16 | with: 17 | python-version: '3.x' 18 | 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install flit 23 | 24 | - name: Build package 25 | run: flit build 26 | 27 | - name: Publish package 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_API_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | 7 | build: 8 | runs-on: ubuntu-latest 9 | environment: testing 10 | strategy: 11 | matrix: 12 | python: [3.8, 3.9, "3.10", "3.11"] 13 | 14 | steps: 15 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 16 | - name: Setup Python 17 | uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4 18 | with: 19 | python-version: ${{ matrix.python }} 20 | 21 | - name: Install Tox 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install tox 25 | 26 | - name: Run Tox 27 | # Run tox using the version of Python in `PATH` 28 | run: tox -e py 29 | env: 30 | TSS_USERNAME: ${{ secrets.TSS_USERNAME }} 31 | TSS_PASSWORD: ${{ secrets.TSS_PASSWORD }} 32 | TSS_TENANT: ${{ secrets.TSS_TENANT }} 33 | TSS_SECRET_ID: ${{ secrets.TSS_SECRET_ID }} 34 | TSS_SECRET_PATH: ${{ secrets.TSS_SECRET_PATH }} 35 | TSS_FOLDER_ID: ${{ secrets.TSS_FOLDER_ID }} 36 | TSS_FOLDER_PATH: ${{ secrets.TSS_FOLDER_PATH }} 37 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Editor Configs 132 | .vscode/ 133 | .idea/ 134 | 135 | # Dotenv 136 | .env 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Delinea Inc. 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Delinea Secret Server Python SDK 2 | 3 | 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | 6 | ![PyPI Version](https://img.shields.io/pypi/v/python-tss-sdk) ![License](https://img.shields.io/github/license/DelineaXPM/python-tss-sdk) ![Python Versions](https://img.shields.io/pypi/pyversions/python-tss-sdk) 7 | 8 | The [Delinea](https://delinea.com/) [Secret Server](https://delinea.com/products/secret-server/) Python SDK contains classes that interact with Secret Server via the REST API. 9 | 10 | ## Install 11 | 12 | ```shell 13 | python -m pip install python-tss-sdk 14 | ``` 15 | 16 | ## Secret Server Authentication 17 | 18 | There are three ways in which you can authorize the `SecretServer` and `SecretServerCloud` classes to fetch secrets. 19 | 20 | - Password Authorization (with `PasswordGrantAuthorizer`) 21 | - Domain Authorization (with `DomainPasswordGrantAuthorizer`) 22 | - Access Token Authorization (with `AccessTokenAuthorizer`) 23 | 24 | ### Usage 25 | 26 | 27 | #### Password Authorization 28 | 29 | If using traditional `username` and `password` authentication to log in to your Secret Server, you can pass the `PasswordGrantAuthorizer` into the `SecretServer` class at instantiation. The `PasswordGrantAuthorizer` requires a `base_url`, `username`, and `password`. It optionally takes a `token_path_uri`, but defaults to `/oauth2/token`. 30 | 31 | ```python 32 | from delinea.secrets.server import PasswordGrantAuthorizer 33 | 34 | authorizer = PasswordGrantAuthorizer("https://hostname/SecretServer", os.getenv("myusername"), os.getenv("password")") 35 | ``` 36 | 37 | #### Domain Authorization 38 | 39 | To use a domain credential, use the `DomainPasswordGrantAuthorizer`. It requires a `base_url`, `username`, `domain`, and `password`. It optionally takes a `token_path_uri`, but defaults to `/oauth2/token`. 40 | 41 | ```python 42 | from delinea.secrets.server import DomainPasswordGrantAuthorizer 43 | 44 | authorizer = DomainPasswordGrantAuthorizer("https://hostname/SecretServer", os.getenv("myusername"), os.getenv("mydomain"), os.getenv("password")) 45 | ``` 46 | 47 | #### Access Token Authorization 48 | 49 | If you already have an `access_token`, you can pass directly via the `AccessTokenAuthorizer`. 50 | 51 | ```python 52 | from delinea.secrets.server import AccessTokenAuthorizer 53 | 54 | authorizer = AccessTokenAuthorizer("AgJ1slfZsEng9bKsssB-tic0Kh8I...") 55 | ``` 56 | 57 | ## Secret Server Cloud 58 | 59 | The SDK API requires an `Authorizer` and a `tenant`. 60 | 61 | `tenant` simplifies the configuration when using Secret Server Cloud by assuming the default folder structure and creating the _base URL_ from a template that takes the `tenant` and an optional top-level domain (TLD) that defaults to `com`, as parameters. 62 | 63 | ### Useage 64 | 65 | Instantiate the `SecretServerCloud` class with `tenant` and an `Authorizer` (optionally include a `tld`). To retrieve a secret, pass an integer `id` to `get_secret()` which will return the secret as a JSON encoded string. 66 | 67 | ```python 68 | from delinea.secrets.server import SecretServerCloud 69 | 70 | secret_server = SecretServerCloud(tenant=tenant, authorizer=authorizer) 71 | 72 | secret = secret_server.get_secret(os.getenv("TSS_SECRET_ID")) 73 | 74 | serverSecret = ServerSecret(**secret) 75 | 76 | print(f"username: {serverSecret.fields['username'].value}\npassword: {serverSecret.fields['password'].value}") 77 | ``` 78 | 79 | The SDK API also contains a `Secret` `@dataclass` containing a subset of the Secret's attributes and a dictionary of all the fields keyed by the Secret's `slug`. 80 | 81 | ## Initializing SecretServer 82 | 83 | ### Useage 84 | 85 | > NOTE: In v1.0.0 `SecretServer` replaces `SecretServerV1`. However, `SecretServerV0` is available to use instead, for backwards compatibility with v0.0.5 and v0.0.6. 86 | 87 | To instantiate the `SecretServer` class, it requires a `base_url`, an `Authorizer` object (see above), and an optional `api_path_uri` (defaults to `"/api/v1"`) 88 | 89 | ```python 90 | from delinea.secrets.server import SecretServer 91 | 92 | secret_server = SecretServer("https://hostname/SecretServer", authorizer=authorizer) 93 | ``` 94 | 95 | Secrets can be fetched using the `get_secret` method, which takes an integer `id` of the secret and, returns a `json` object: 96 | 97 | ```python 98 | secret = secret_server.get_secret(os.getenv("TSS_SECRET_ID")) 99 | 100 | serverSecret = ServerSecret(**secret) 101 | 102 | print(f"username: {serverSecret.fields['username'].value}\npassword: {serverSecret.fields['password'].value}") 103 | ``` 104 | 105 | Alternatively, you can use pass the json to `ServerSecret` which returns a `dataclass` object representation of the secret: 106 | 107 | ```shell 108 | from delinea.secrets.server import ServerSecret 109 | 110 | secret = ServerSecret(**secret_server.get_secret(os.getenv("TSS_SECRET_ID"))) 111 | 112 | username = secret.fields['username'].value 113 | ``` 114 | 115 | It is also now possible to fetch a secret by the secrets `path` using the `get_secret_by_path` method on the `SecretServer` object. This, too, returns a `json` object. 116 | 117 | ```python 118 | secret = secret_server.get_secret_by_path(r"TSS_SECRET_PATH") 119 | 120 | serverSecret = ServerSecret(**secret) 121 | 122 | print(f"username: {serverSecret.fields['username'].value}\npassword: {serverSecret.fields['password'].value}") 123 | ``` 124 | 125 | > Note: Add a try-except block to the code to get more detailed error messages. 126 | 127 | ```python 128 | from delinea.secrets.server import SecretServerError 129 | 130 | try: 131 | # code... 132 | except SecretServerError as e: 133 | print(e.message) 134 | ``` 135 | 136 | > Note: The `path` must be the full folder path and name of the secret. 137 | 138 | ## Using Self-Signed Certificates 139 | 140 | When using a self-signed certificate for SSL, the `REQUESTS_CA_BUNDLE` environment variable should be set to the path of the certificate (in `.pem` format). This will negate the need to ignore SSL certificate verification, which makes your application vunerable. Please reference the [`requests` documentation](https://docs.python.org/3/library/ssl.html) for further details on the `REQUESTS_CA_BUNDLE` environment variable, should you require it. 141 | 142 | ## Create a Build Environment (optional) 143 | 144 | The SDK requires [Python 3.8](https://www.python.org/downloads/) or higher. 145 | 146 | First, ensure Python is in `$PATH`, then run: 147 | 148 | ```shell 149 | # Clone the repo 150 | git clone https://github.com/DelineaXPM/python-tss-sdk 151 | cd python-tss-sdk 152 | 153 | # Create a virtual environment 154 | python -m venv venv 155 | . venv/bin/activate 156 | 157 | # Install dependencies 158 | python -m pip install --upgrade pip 159 | pip install -r requirements.txt 160 | ``` 161 | 162 | Valid credentials are required to run the unit tests. The credentials should be stored in environment variables or in a `.env` file: 163 | 164 | ```shell 165 | export TSS_USERNAME=myusername 166 | export TSS_PASSWORD=mysecretpassword 167 | export TSS_TENANT=mytenant 168 | export TSS_SECRET_ID=42 169 | export TSS_SECRET_PATH=\Test Secrets\SecretName 170 | export TSS_FOLDER_ID=1 171 | export TSS_FOLDER_PATH=\Test Secrets 172 | ``` 173 | 174 | The tests assume that the user associated with the specified `TSS_USERNAME` and `TSS_PASSWORD` can read the secret to be fetched, and that the Secret itself contains `username` and `password` fields. 175 | 176 | To run the tests with `tox`: 177 | 178 | ```shell 179 | tox 180 | ``` 181 | 182 | To build the package, use [Flit](https://flit.readthedocs.io/en/latest/): 183 | 184 | ```shell 185 | flit build 186 | ``` 187 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from dotenv import load_dotenv 4 | from delinea.secrets.server import PasswordGrantAuthorizer, SecretServerCloud 5 | 6 | load_dotenv() 7 | 8 | 9 | @pytest.fixture 10 | def env_vars(): 11 | return { 12 | "username": os.getenv("TSS_USERNAME"), 13 | "password": os.getenv("TSS_PASSWORD"), 14 | "tenant": os.getenv("TSS_TENANT"), 15 | "secret_id": os.getenv("TSS_SECRET_ID"), 16 | "secret_path": os.getenv("TSS_SECRET_PATH"), 17 | "folder_id": os.getenv("TSS_FOLDER_ID"), 18 | "folder_path": os.getenv("TSS_FOLDER_PATH"), 19 | } 20 | 21 | 22 | @pytest.fixture 23 | def authorizer(env_vars): 24 | return PasswordGrantAuthorizer( 25 | f"https://{env_vars['tenant']}.secretservercloud.com", 26 | env_vars["username"], 27 | env_vars["password"], 28 | ) 29 | 30 | 31 | @pytest.fixture 32 | def secret_server(env_vars, authorizer): 33 | return SecretServerCloud(env_vars["tenant"], authorizer) 34 | -------------------------------------------------------------------------------- /delinea/__init__.py: -------------------------------------------------------------------------------- 1 | """The Delinea Secret Server Python SDK""" 2 | 3 | __version__ = "1.2.3" 4 | -------------------------------------------------------------------------------- /delinea/secrets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DelineaXPM/python-tss-sdk/17e733c4b0329e67308df515c60fd580aea69cc7/delinea/secrets/__init__.py -------------------------------------------------------------------------------- /delinea/secrets/server.py: -------------------------------------------------------------------------------- 1 | """The Delinea Secret Server SDK API facilitates access to the Secret Server 2 | REST API using *OAuth2 Bearer Token* authentication. 3 | 4 | Example: 5 | 6 | # connect to Secret Server 7 | secret_server = SecretServer(base_url, authorizer, api_path_uri='/api/v1') 8 | # or, for Secret Server Cloud 9 | secret_server = SecretServerCloud(tenant, authorizer, tld='com') 10 | 11 | # to get the secret as a ``dict`` 12 | secret = secret_server.get_secret(123) 13 | # or, to use the dataclass 14 | secret = ServerSecret(**secret_server.get_secret(123)) 15 | """ 16 | 17 | import json 18 | import re 19 | from abc import ABC, abstractmethod 20 | from dataclasses import dataclass 21 | from datetime import datetime, timedelta 22 | 23 | import requests 24 | 25 | 26 | @dataclass 27 | class ServerSecret: 28 | # Based on https://gist.github.com/jaytaylor/3660565 29 | @staticmethod 30 | def snake_case(camel_cased): 31 | """Transform to snake case 32 | 33 | Transforms the keys of the given map from camelCase to snake_case. 34 | """ 35 | return [ 36 | ( 37 | re.compile("([a-z0-9])([A-Z])") 38 | .sub(r"\1_\2", re.compile(r"(.)([A-Z][a-z]+)").sub(r"\1_\2", k)) 39 | .lower(), 40 | v, 41 | ) 42 | for (k, v) in camel_cased.items() 43 | ] 44 | 45 | @dataclass 46 | class Field: 47 | item_id: int 48 | field_id: int 49 | file_attachment_id: int 50 | field_description: str 51 | field_name: str 52 | filename: str 53 | value: str 54 | slug: str 55 | 56 | def __init__(self, **kwargs): 57 | # The REST API returns attributes with camelCase names which we 58 | # replace with snake_case per Python conventions 59 | for k, v in ServerSecret.snake_case(kwargs): 60 | if k == "item_value": 61 | k = "value" 62 | setattr(self, k, v) 63 | 64 | id: int 65 | folder_id: int 66 | secret_template_id: int 67 | site_id: int 68 | active: bool 69 | checked_out: bool 70 | check_out_enabled: bool 71 | name: str 72 | secret_template_name: str 73 | last_heart_beat_status: str 74 | last_heart_beat_check: datetime 75 | last_password_change_attempt: datetime 76 | fields: dict 77 | 78 | DEFAULT_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S" 79 | 80 | def __init__(self, **kwargs): 81 | # The REST API returns attributes with camelCase names which we replace 82 | # with snake_case per Python conventions 83 | datetime_format = self.DEFAULT_DATETIME_FORMAT 84 | if "datetime_format" in kwargs: 85 | datetime_format = kwargs["datetime_format"] 86 | for k, v in self.snake_case(kwargs): 87 | if k in ["last_heart_beat_check", "last_password_change_attempt"]: 88 | # @dataclass does not marshal timestamps into datetimes automatically 89 | v = re.sub(r"\.[0-9]+$", "", v) 90 | v = datetime.strptime(v, datetime_format) 91 | setattr(self, k, v) 92 | self.fields = { 93 | item["slug"]: ServerSecret.Field(**item) for item in kwargs["items"] 94 | } 95 | 96 | 97 | @dataclass 98 | class ServerFolder: 99 | # Based on https://gist.github.com/jaytaylor/3660565 100 | @staticmethod 101 | def snake_case(camel_cased): 102 | """Transform to snake case 103 | 104 | Transforms the keys of the given map from camelCase to snake_case. 105 | """ 106 | return [ 107 | ( 108 | re.compile("([a-z0-9])([A-Z])") 109 | .sub(r"\1_\2", re.compile(r"(.)([A-Z][a-z]+)").sub(r"\1_\2", k)) 110 | .lower(), 111 | v, 112 | ) 113 | for (k, v) in camel_cased.items() 114 | ] 115 | 116 | @dataclass 117 | class Field: 118 | item_id: int 119 | value: str 120 | slug: str 121 | 122 | def __init__(self, **kwargs): 123 | # The REST API returns attributes with camelCase names which we 124 | # replace with snake_case per Python conventions 125 | for k, v in ServerSecret.snake_case(kwargs): 126 | if k == "item_value": 127 | k = "value" 128 | setattr(self, k, v) 129 | 130 | id: int 131 | folder_name: str 132 | folder_path: str 133 | parent_folder_id: int 134 | folder_type_id: int 135 | secret_policy_id: int 136 | inherit_secret_policy: bool 137 | inherit_permissions: bool 138 | child_folders: list 139 | secret_templates: list 140 | 141 | def __init__(self, **kwargs): 142 | # The REST API returns attributes with camelCase names which we replace 143 | # with snake_case per Python conventions 144 | for k, v in self.snake_case(kwargs): 145 | setattr(self, k, v) 146 | 147 | 148 | class SecretServerError(Exception): 149 | """An Exception that includes a message and the server response""" 150 | 151 | def __init__(self, message, response=None, *args, **kwargs): 152 | self.message = message 153 | super().__init__(*args, **kwargs) 154 | 155 | 156 | class SecretServerClientError(SecretServerError): 157 | """An Exception that represents a client error i.e. ``400``.""" 158 | 159 | 160 | class SecretServerServiceError(SecretServerError): 161 | """An Exception that represents a service error i.e. ``500``.""" 162 | 163 | 164 | class Authorizer(ABC): 165 | """Main abstract base class for all Authorizer access methods.""" 166 | 167 | @staticmethod 168 | def add_bearer_token_authorization_header(bearer_token, existing_headers={}): 169 | """Adds an HTTP `Authorization` header containing the `Bearer` token 170 | 171 | :param existing_headers: a ``dict`` containing the existing headers 172 | :return: a ``dict`` containing the `existing_headers` and the 173 | `Authorization` header 174 | :rtype: ``dict`` 175 | """ 176 | 177 | return { 178 | "Authorization": "Bearer " + bearer_token, 179 | **existing_headers, 180 | } 181 | 182 | @abstractmethod 183 | def get_access_token(self): 184 | """Returns the access_token from a Grant Request""" 185 | 186 | def headers(self, existing_headers={}): 187 | """Returns a dictionary containing headers for REST API calls""" 188 | return self.add_bearer_token_authorization_header( 189 | self.get_access_token(), existing_headers 190 | ) 191 | 192 | 193 | class AccessTokenAuthorizer(Authorizer): 194 | """Allows the use of a pre-existing access token to authorize REST API 195 | calls. 196 | """ 197 | 198 | def get_access_token(self): 199 | return self.access_token 200 | 201 | def __init__(self, access_token): 202 | self.access_token = access_token 203 | 204 | 205 | class PasswordGrantAuthorizer(Authorizer): 206 | """Allows the use of a username and password to be used to authorize REST 207 | API calls. 208 | """ 209 | 210 | TOKEN_PATH_URI = "/oauth2/token" 211 | 212 | @staticmethod 213 | def get_access_grant(token_url, grant_request): 214 | """Gets an *OAuth2 Access Grant* by calling the Secret Server REST API 215 | ``token`` endpoint 216 | 217 | :raise :class:`SecretServerError` when the server returns anything 218 | other than a valid Access Grant 219 | """ 220 | 221 | response = requests.post(token_url, grant_request, timeout=60) 222 | 223 | try: # TSS returns a 200 (OK) containing HTML for some error conditions 224 | return json.loads(SecretServer.process(response).content) 225 | except json.JSONDecodeError: 226 | raise SecretServerError(response) 227 | 228 | def _refresh(self, seconds_of_drift=300): 229 | """Refreshes the *OAuth2 Access Grant* if it has expired or will in the next 230 | `seconds_of_drift` seconds. 231 | 232 | :raise :class:`SecretServerError` when the server returns anything other 233 | than a valid Access Grant 234 | """ 235 | 236 | if ( 237 | hasattr(self, "access_grant") 238 | and self.access_grant_refreshed 239 | + timedelta(seconds=self.access_grant["expires_in"] + seconds_of_drift) 240 | > datetime.now() 241 | ): 242 | return 243 | else: 244 | self.access_grant = self.get_access_grant( 245 | self.token_url, self.grant_request 246 | ) 247 | self.access_grant_refreshed = datetime.now() 248 | 249 | def __init__(self, base_url, username, password, token_path_uri=TOKEN_PATH_URI): 250 | self.token_url = base_url.rstrip("/") + "/" + token_path_uri.strip("/") 251 | self.grant_request = { 252 | "username": username, 253 | "password": password, 254 | "grant_type": "password", 255 | } 256 | 257 | def get_access_token(self): 258 | self._refresh() 259 | return self.access_grant["access_token"] 260 | 261 | 262 | class DomainPasswordGrantAuthorizer(PasswordGrantAuthorizer): 263 | """Allows domain access to be used to authorize REST API calls.""" 264 | 265 | def __init__( 266 | self, 267 | base_url, 268 | username, 269 | domain, 270 | password, 271 | token_path_uri=PasswordGrantAuthorizer.TOKEN_PATH_URI, 272 | ): 273 | super().__init__(base_url, username, password, token_path_uri=token_path_uri) 274 | self.grant_request["domain"] = domain 275 | 276 | 277 | class SecretServer: 278 | """A class that uses an *OAuth2 Bearer Token* to access the Secret Server 279 | REST API. It uses the and `Authorizer` to determine the Authorization 280 | method required to access the Secret Server at :attr:`base_url`. 281 | 282 | It gets an ``access_token`` that it uses to create an *HTTP Authorization 283 | Header* which it includes in each REST API call. 284 | """ 285 | 286 | API_PATH_URI = "/api/v1" 287 | 288 | @staticmethod 289 | def process(response): 290 | """Process the response raising an error if the call was unsuccessful 291 | 292 | :return: the response if the call was successful 293 | :rtype: :class:`~requests.Response` 294 | :raises: :class:`SecretServerAccessError` when the caller does not have 295 | access to the secret 296 | :raises: :class:`SecretsAccessError` when the server responses with any 297 | other error 298 | """ 299 | 300 | if response.status_code >= 200 and response.status_code < 300: 301 | return response 302 | if response.status_code >= 400 and response.status_code < 500: 303 | try: 304 | content = json.loads(response.content) 305 | if "message" in content: 306 | message = content["message"] 307 | elif "error" in content and isinstance(content["error"], str): 308 | message = content["error"] 309 | except json.JSONDecodeError as err: 310 | message = err.msg 311 | raise SecretServerClientError(message, response) 312 | else: 313 | raise SecretServerServiceError(response) 314 | 315 | def headers(self): 316 | """Returns a dictionary containing HTTP headers.""" 317 | return self.authorizer.headers() 318 | 319 | def __init__( 320 | self, 321 | base_url, 322 | authorizer: Authorizer, 323 | api_path_uri=API_PATH_URI, 324 | ): 325 | """ 326 | :param base_url: The base URL e.g. ``http://localhost/SecretServer`` 327 | :type base_url: str 328 | :param authorizer: The authorization method to be used 329 | :type authorizer: Authorizer 330 | :param api_path_uri: Defaults to ``/api/v1`` 331 | :type api_path_uri: str 332 | """ 333 | 334 | self.base_url = base_url.rstrip("/") 335 | self.authorizer = authorizer 336 | self.api_url = f"{self.base_url}/{api_path_uri.strip('/')}" 337 | 338 | def get_secret_json(self, id, query_params=None): 339 | """Gets a Secret from Secret Server 340 | 341 | :param id: the id of the secret 342 | :type id: int 343 | :param query_params: query parameters to pass to the endpoint 344 | :type query_params: dict 345 | :return: a JSON formatted string representation of the secret 346 | :rtype: ``str`` 347 | :raise: :class:`SecretServerAccessError` when the caller does not have 348 | permission to access the secret 349 | :raise: :class:`SecretServerError` when the REST API call fails for 350 | any other reason 351 | """ 352 | endpoint_url = f"{self.api_url}/secrets/{id}" 353 | 354 | if query_params is None: 355 | return self.process( 356 | requests.get(endpoint_url, headers=self.headers(), timeout=60) 357 | ).text 358 | else: 359 | return self.process( 360 | requests.get( 361 | endpoint_url, 362 | params=query_params, 363 | headers=self.headers(), 364 | timeout=60, 365 | ) 366 | ).text 367 | 368 | def get_folder_json(self, id, query_params=None, get_all_children=True): 369 | """Gets a Folder from Secret Server 370 | 371 | :param id: the id of the folder 372 | :type id: int 373 | :param query_params: query parameters to pass to the endpoint 374 | :type query_params: dict 375 | :return: a JSON formatted string representation of the folder 376 | :rtype: ``str`` 377 | :raise: :class:`SecretServerAccessError` when the caller does not have 378 | permission to access the folder 379 | :raise: :class:`SecretServerError` when the REST API call fails for 380 | any other reason 381 | """ 382 | endpoint_url = f"{self.api_url}/folders/{id}" 383 | 384 | if get_all_children: 385 | query_params["getAllChildren"] = "true" 386 | 387 | if query_params is None: 388 | return self.process(requests.get(endpoint_url, headers=self.headers())).text 389 | else: 390 | return self.process( 391 | requests.get( 392 | endpoint_url, 393 | params=query_params, 394 | headers=self.headers(), 395 | ) 396 | ).text 397 | 398 | def get_secret(self, id, fetch_file_attachments=True, query_params=None): 399 | """Gets a secret 400 | 401 | :param id: the id of the secret 402 | :type id: int 403 | :param fetch_file_attachments: whether or not to fetch file attachments 404 | and replace itemValue with the contents 405 | for each item (field), automatically 406 | :type fetch_file_attachments: bool 407 | :param query_params: query parameters to pass to the endpoint 408 | :type query_params: dict 409 | :return: a ``dict`` representation of the secret 410 | :rtype: ``dict`` 411 | :raise: :class:`SecretServerAccessError` when the caller does not have 412 | permission to access the secret 413 | :raise: :class:`SecretServerError` when the REST API call fails for 414 | any other reason 415 | """ 416 | 417 | response = self.get_secret_json(id, query_params=query_params) 418 | 419 | try: 420 | secret = json.loads(response) 421 | except json.JSONDecodeError: 422 | raise SecretServerError(response) 423 | 424 | if fetch_file_attachments: 425 | for item in secret["items"]: 426 | if item["fileAttachmentId"]: 427 | endpoint_url = f"{self.api_url}/secrets/{id}/fields/{item['slug']}" 428 | if query_params is None: 429 | item["itemValue"] = self.process( 430 | requests.get( 431 | endpoint_url, headers=self.headers(), timeout=60 432 | ) 433 | ) 434 | else: 435 | item["itemValue"] = self.process( 436 | requests.get( 437 | endpoint_url, 438 | params=query_params, 439 | headers=self.headers(), 440 | timeout=60, 441 | ) 442 | ) 443 | return secret 444 | 445 | def get_folder(self, id, query_params=None, get_all_children=False): 446 | """Gets a folder 447 | 448 | :param id: the id of the folder 449 | :type id: int 450 | :param getAllChildren: Whether to retrieve all child folders of the requested folder 451 | :type fetch_file_attachments: bool 452 | :param query_params: query parameters to pass to the endpoint 453 | :type query_params: dict 454 | :return: a ``dict`` representation of the folder 455 | :rtype: ``dict`` 456 | :raise: :class:`SecretServerAccessError` when the caller does not have 457 | permission to access the folder 458 | :raise: :class:`SecretServerError` when the REST API call fails for 459 | any other reason 460 | """ 461 | 462 | response = self.get_folder_json( 463 | id, query_params=query_params, get_all_children=get_all_children 464 | ) 465 | 466 | try: 467 | folder = json.loads(response) 468 | except json.JSONDecodeError: 469 | raise SecretServerError(response) 470 | 471 | return folder 472 | 473 | def get_secret_by_path(self, secret_path, fetch_file_attachments=True): 474 | """Gets a secret by path 475 | 476 | :param secret_path: full path of the secret 477 | :type secret_path: str 478 | :param fetch_file_attachments: whether or not to fetch file attachments 479 | and replace itemValue with the contents 480 | for each item (field), automatically 481 | :type fetch_file_attachments: bool 482 | :return: a ``dict`` representation of the secret 483 | :rtype: ``dict`` 484 | """ 485 | path = "\\" + re.sub(r"[\\/]+", r"\\", secret_path).lstrip("\\").rstrip("\\") 486 | 487 | params = {"secretPath": path} 488 | return self.get_secret( 489 | id=0, 490 | fetch_file_attachments=fetch_file_attachments, 491 | query_params=params, 492 | ) 493 | 494 | def get_folder_by_path(self, folder_path, get_all_children=True): 495 | """Gets a folder by path 496 | 497 | :param folder_path: full path of the folder 498 | :type folder_path: str 499 | :return: a ``dict`` representation of the folder 500 | :rtype: ``dict`` 501 | """ 502 | path = "\\" + re.sub(r"[\\/]+", r"\\", folder_path).lstrip("\\").rstrip("\\") 503 | 504 | params = {"folderPath": path} 505 | return self.get_folder( 506 | id=0, 507 | get_all_children=get_all_children, 508 | query_params=params, 509 | ) 510 | 511 | def search_secrets(self, query_params=None): 512 | """Get Secrets from Secret Server 513 | 514 | :param query_params: query parameters to pass to the endpoint 515 | :type query_params: dict 516 | :return: a JSON formatted string representation of the secrets 517 | :rtype: ``str`` 518 | :raise: :class:`SecretServerAccessError` when the caller does not have 519 | permission to access the secret 520 | :raise: :class:`SecretServerError` when the REST API call fails for 521 | any other reason 522 | """ 523 | endpoint_url = f"{self.api_url}/secrets" 524 | 525 | if query_params is None: 526 | return self.process( 527 | requests.get(endpoint_url, headers=self.headers(), timeout=60) 528 | ).text 529 | else: 530 | return self.process( 531 | requests.get( 532 | endpoint_url, 533 | params=query_params, 534 | headers=self.headers(), 535 | timeout=60, 536 | ) 537 | ).text 538 | 539 | def lookup_folders(self, query_params=None): 540 | """Lookup Folders from Secret Server 541 | 542 | :param query_params: query parameters to pass to the endpoint 543 | :type query_params: dict 544 | :return: a JSON formatted string representation of the folders, containing only id and name 545 | :rtype: ``str`` 546 | :raise: :class:`SecretServerAccessError` when the caller does not have 547 | permission to access the secret 548 | :raise: :class:`SecretServerError` when the REST API call fails for 549 | any other reason 550 | """ 551 | endpoint_url = f"{self.api_url}/folders/lookup" 552 | 553 | if query_params is None: 554 | return self.process(requests.get(endpoint_url, headers=self.headers())).text 555 | else: 556 | return self.process( 557 | requests.get( 558 | endpoint_url, 559 | params=query_params, 560 | headers=self.headers(), 561 | ) 562 | ).text 563 | 564 | def get_secret_ids_by_folderid(self, folder_id): 565 | """Gets a list of secrets ids by folder_id 566 | 567 | :param folder_id: the id of the folder 568 | :type id: int 569 | :return: a ``list`` of the secret id's 570 | :rtype: ``list`` 571 | :raise: :class:`SecretServerAccessError` when the caller does not have 572 | permission to access the secret 573 | :raise: :class:`SecretServerError` when the REST API call fails for 574 | any other reason 575 | """ 576 | 577 | params = {"filter.folderId": folder_id} 578 | endpoint_url = f"{self.api_url}/secrets/search-total" 579 | params["take"] = self.process( 580 | requests.get( 581 | endpoint_url, params=params, headers=self.headers(), timeout=60 582 | ) 583 | ).text 584 | response = self.search_secrets(query_params=params) 585 | 586 | try: 587 | secrets = json.loads(response) 588 | except json.JSONDecodeError: 589 | raise SecretServerError(response) 590 | 591 | secret_ids = [] 592 | for secret in secrets["records"]: 593 | secret_ids.append(secret["id"]) 594 | 595 | return secret_ids 596 | 597 | def get_child_folder_ids_by_folderid(self, folder_id): 598 | """Gets a list of child folder ids by folder_id 599 | :param folder_id: the id of the folder 600 | :type id: int 601 | :return: a ``list`` of the child folder id's 602 | :rtype: ``list`` 603 | :raise: :class:`SecretServerAccessError` when the caller does not have 604 | permission to access the secret 605 | :raise: :class:`SecretServerError` when the REST API call fails for 606 | any other reason 607 | """ 608 | 609 | params = { 610 | "filter.parentFolderId": folder_id, 611 | "filter.limitToDirectDescendents": True, 612 | } 613 | params["take"] = 1 614 | endpoint_url = f"{self.api_url}/folders/lookup" 615 | 616 | params["take"] = self.process( 617 | requests.get(endpoint_url, params=params, headers=self.headers()) 618 | ).json()["total"] 619 | # Handle result of zero child folders 620 | if params["take"] != 0: 621 | response = self.lookup_folders(query_params=params) 622 | 623 | try: 624 | response = json.loads(response) 625 | except json.JSONDecodeError: 626 | raise SecretServerError(response) 627 | 628 | child_folder_ids = [] 629 | for childFolder in response["records"]: 630 | child_folder_ids.append(childFolder["id"]) 631 | 632 | return child_folder_ids 633 | else: 634 | return [] 635 | 636 | 637 | class SecretServerV0(SecretServer): 638 | """A class that uses an *OAuth2 Bearer Token* to access the Secret Server 639 | REST API. It uses the :attr:`username` and :attr:`password` to access the 640 | Secret Server at :attr:`base_url`. 641 | 642 | It gets an ``access_token`` that it uses to create an *HTTP Authorization 643 | Header* which it includes in each REST API call. 644 | 645 | This class maintains backwards compatibility with v0.0.5 646 | """ 647 | 648 | def __init__( 649 | self, 650 | base_url, 651 | username, 652 | password, 653 | api_path_uri=SecretServer.API_PATH_URI, 654 | token_path_uri=PasswordGrantAuthorizer.TOKEN_PATH_URI, 655 | ): 656 | super().__init__( 657 | base_url, 658 | PasswordGrantAuthorizer( 659 | f"{base_url}/{token_path_uri.strip('/')}", username, password 660 | ), 661 | api_path_uri, 662 | ) 663 | 664 | 665 | class SecretServerCloud(SecretServer): 666 | """A class that uses bearer token authentication to access the Secret 667 | Server Cloud REST API. 668 | 669 | It uses :attr:`tenant`, :attr:`tld` with :attr:`SERVER_URL_TEMPLATE`, 670 | to create request URLs. 671 | 672 | It uses the :attr:`username` and :attr:`password` to get an access_token 673 | from Secret Server Cloud which it uses to make calls to the REST API. 674 | """ 675 | 676 | DEFAULT_TLD = "com" 677 | URL_TEMPLATE = "https://{}.secretservercloud.{}" 678 | 679 | def __init__(self, tenant, authorizer: Authorizer, tld=DEFAULT_TLD): 680 | super().__init__(self.URL_TEMPLATE.format(tenant, tld), authorizer) 681 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import os 2 | from conftest import secret_server 3 | 4 | from delinea.secrets.server import ( 5 | SecretServer, 6 | SecretServerCloud, 7 | SecretServerError, 8 | PasswordGrantAuthorizer, 9 | ServerSecret, 10 | ) 11 | 12 | if __name__ == "__main__": 13 | tenant = os.getenv("TSS_TENANT") 14 | base_url = f"https://{tenant}.secretservercloud.com" 15 | authorizer = PasswordGrantAuthorizer( 16 | base_url, 17 | os.getenv("TSS_USERNAME"), 18 | os.getenv("TSS_PASSWORD"), 19 | ) 20 | 21 | secret_server_cloud = SecretServerCloud(tenant=tenant, authorizer=authorizer) 22 | 23 | try: 24 | secret = secret_server_cloud.get_secret(os.getenv("TSS_SECRET_ID")) 25 | serverSecret = ServerSecret(**secret) 26 | print( 27 | f"""username: {serverSecret.fields['username'].value} 28 | password: {serverSecret.fields['password'].value} 29 | template: {serverSecret.secret_template_name}""" 30 | ) 31 | except SecretServerError as error: 32 | print(error.response.text) 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core ==3.9.0"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "delinea" 7 | author = "Delinea Integrations" 8 | author-email = "GitHub@delinea.com" 9 | classifiers = [ 10 | "License :: OSI Approved :: Apache Software License", 11 | "Operating System :: OS Independent", 12 | "Programming Language :: Python :: 3.8", 13 | "Programming Language :: Python :: 3.9", 14 | "Programming Language :: Python :: 3.10", 15 | "Programming Language :: Python :: 3.11" 16 | ] 17 | description-file = "README.md" 18 | requires = [ 19 | "requests >= 2.12.5" 20 | ] 21 | requires-python=">=3.8" 22 | dist-name = "python-tss-sdk" 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "DelineaXPM/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.3 2 | tox 3 | pytest 4 | python-dotenv 5 | flit 6 | black 7 | urllib3==2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability 8 | zipp==3.20.1 # not directly required, pinned by Snyk to avoid a vulnerability 9 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from delinea.secrets.server import ( 4 | AccessTokenAuthorizer, 5 | SecretServer, 6 | SecretServerClientError, 7 | SecretServerError, 8 | ServerSecret, 9 | ServerFolder, 10 | ) 11 | 12 | 13 | def test_bad_url(env_vars, authorizer): 14 | bad_server = SecretServer( 15 | f"https://{env_vars['tenant']}.secretservercloud.com/nonexistent", 16 | authorizer, 17 | ) 18 | with pytest.raises(SecretServerError): 19 | bad_server.get_secret(env_vars["secret_id"]) 20 | 21 | 22 | def test_token_url(env_vars, authorizer): 23 | assert ( 24 | authorizer.token_url 25 | == f"https://{env_vars['tenant']}.secretservercloud.com/oauth2/token" 26 | ) 27 | 28 | 29 | def test_api_url(secret_server, env_vars): 30 | assert ( 31 | secret_server.api_url 32 | == f"https://{env_vars['tenant']}.secretservercloud.com/api/v1" 33 | ) 34 | 35 | 36 | def test_access_token_authorizer(env_vars, authorizer): 37 | assert SecretServer( 38 | f"https://{env_vars['tenant']}.secretservercloud.com/", 39 | AccessTokenAuthorizer(authorizer.get_access_token()), 40 | ).get_secret(env_vars["secret_id"])["id"] == int(env_vars["secret_id"]) 41 | 42 | 43 | def test_server_secret(env_vars, secret_server): 44 | assert ServerSecret(**secret_server.get_secret(env_vars["secret_id"])).id == int( 45 | env_vars["secret_id"] 46 | ) 47 | 48 | 49 | def test_server_secret_by_path(env_vars, secret_server): 50 | assert ServerSecret( 51 | **secret_server.get_secret_by_path(env_vars["secret_path"]) 52 | ).id == int(env_vars["secret_id"]) 53 | 54 | 55 | def test_server_folder_by_path(env_vars, secret_server): 56 | assert ServerFolder( 57 | **secret_server.get_folder_by_path(env_vars["folder_path"]) 58 | ).id == int(env_vars["folder_id"]) 59 | 60 | 61 | def test_nonexistent_secret(secret_server): 62 | with pytest.raises(SecretServerClientError): 63 | secret_server.get_secret(1000) 64 | 65 | 66 | def test_nonexistent_folder(secret_server): 67 | with pytest.raises(SecretServerClientError): 68 | secret_server.get_folder(1000) 69 | 70 | 71 | def test_server_secret_ids_by_folderid(env_vars, secret_server): 72 | assert type(secret_server.get_secret_ids_by_folderid(env_vars["folder_id"])) is list 73 | 74 | 75 | def test_server_child_folder_ids_by_folderid(env_vars, secret_server): 76 | assert ( 77 | type(secret_server.get_child_folder_ids_by_folderid(env_vars["folder_id"])) 78 | is list 79 | ) 80 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | # Docs for tox config -> https://tox.readthedocs.io/en/latest/config.html 7 | 8 | [tox] 9 | envlist = 3.8, 3.9, 3.10, 3.11 10 | isolated_build = True 11 | skipsdist = True 12 | 13 | [testenv] 14 | deps = 15 | pytest 16 | requests 17 | python-dotenv 18 | passenv = 19 | TSS_USERNAME 20 | TSS_PASSWORD 21 | TSS_TENANT 22 | TSS_SECRET_ID 23 | TSS_SECRET_PATH 24 | TSS_FOLDER_ID 25 | TSS_FOLDER_PATH 26 | commands = 27 | pytest 28 | --------------------------------------------------------------------------------