diff --git a/doc/source/cli/cli.rst b/doc/source/cli/cli.rst index 6594cdd1..4033c05b 100644 --- a/doc/source/cli/cli.rst +++ b/doc/source/cli/cli.rst @@ -949,7 +949,6 @@ catalog. **-a / --author** (Required) - ``Author`` is intended to document the application or the individual, who generates the site passphrase documents, mostly for tracking purposes. It is expected to be leveraged in an operator-specific manner. @@ -965,6 +964,15 @@ are placed in the following folder structure under ``save_location``: /site//secrets/passphrases/ +**-c / --passphrase-catalog** (Optional). + +Specifies a path for a passphrase catalog file to use instead of the catalogs +found in the repositories specified by the user. The specified catalog +will be used when this option is specified and all other discovered catalogs +will be disregarded. This can be used to specify a subset of passphrases to +generate instead of the whole catalog or for testing new passphrases before +merging them into production. + **-i / --interactive** (Optional). False by default. Enables input prompts for "prompt: true" passphrases. Input prompts are diff --git a/pegleg/cli.py b/pegleg/cli.py index bf14b294..b90658a0 100644 --- a/pegleg/cli.py +++ b/pegleg/cli.py @@ -707,6 +707,15 @@ def generate_pki(site_name, author, days, regenerate_all, save_location): required=True, help='Identifier for the program or person who is generating the secrets ' 'documents') +@click.option( + '-c', + '--passphrase-catalog', + 'passphrase_catalog', + required=False, + type=click.Path(exists=True, dir_okay=False, readable=True), + help='Path to a specific passphrase catalog to generate passphrases from. ' + 'If not specified, defaults to use catalogs discovered in the ' + 'repositories.') @click.option( '-i', '--interactive', @@ -722,11 +731,13 @@ def generate_pki(site_name, author, days, regenerate_all, save_location): show_default=True, help='Force cleartext generation of passphrases. This is not recommended.') def generate_passphrases( - *, site_name, save_location, author, interactive, force_cleartext): + *, site_name, save_location, author, passphrase_catalog, interactive, + force_cleartext): engine.repository.process_repositories(site_name) config.set_global_enc_keys(site_name) engine.secrets.generate_passphrases( - site_name, save_location, author, interactive, force_cleartext) + site_name, save_location, author, passphrase_catalog, interactive, + force_cleartext) @secrets.command( diff --git a/pegleg/engine/generators/passphrase_generator.py b/pegleg/engine/generators/passphrase_generator.py index 4d2f79ee..1b2cf409 100644 --- a/pegleg/engine/generators/passphrase_generator.py +++ b/pegleg/engine/generators/passphrase_generator.py @@ -40,7 +40,12 @@ class PassphraseGenerator(BaseGenerator): Generates passphrases for a given environment, specified in a passphrase catalog. """ - def __init__(self, sitename, save_location, author): + def __init__( + self, + sitename, + save_location, + author, + override_passphrase_catalog=None): """Constructor for ``PassphraseGenerator``. :param str sitename: Site name for which passphrases are generated. @@ -49,11 +54,11 @@ class PassphraseGenerator(BaseGenerator): :param str author: Identifying name of the author generating new certificates. """ - super(PassphraseGenerator, self).__init__(sitename, save_location, author) self._catalog = PassphraseCatalog( - self._sitename, documents=self._documents) + self._sitename, + documents=override_passphrase_catalog or self._documents) def generate(self, interactive=False, force_cleartext=False): """ diff --git a/pegleg/engine/secrets.py b/pegleg/engine/secrets.py index 2738f259..685622c4 100644 --- a/pegleg/engine/secrets.py +++ b/pegleg/engine/secrets.py @@ -138,7 +138,11 @@ def _get_dest_path(repo_base, file_path, save_location): def generate_passphrases( - site_name, save_location, author, interactive=False, + site_name, + save_location, + author, + passphrase_catalog=None, + interactive=False, force_cleartext=False): """ Look for the site passphrase catalogs, and for every passphrase entry in @@ -149,12 +153,19 @@ def generate_passphrases( :param str site_name: The site to read from :param str save_location: Location to write files to :param str author: Author who's generating the files + :param path-like passphrase_catalog: Path to file overriding any other + discovered passphrase catalogs :param bool interactive: Whether to allow user input for passphrases :param bool force_cleartext: Whether to generate results in clear text """ + override_passphrase_catalog = passphrase_catalog + if passphrase_catalog: + override_passphrase_catalog = files.read(passphrase_catalog) - PassphraseGenerator(site_name, save_location, author).generate( - interactive=interactive, force_cleartext=force_cleartext) + PassphraseGenerator( + site_name, save_location, author, + override_passphrase_catalog).generate( + interactive=interactive, force_cleartext=force_cleartext) def generate_crypto_string(length): diff --git a/tests/unit/engine/test_generate_passphrases.py b/tests/unit/engine/test_generate_passphrases.py index 29fec0a6..5608133a 100644 --- a/tests/unit/engine/test_generate_passphrases.py +++ b/tests/unit/engine/test_generate_passphrases.py @@ -68,6 +68,34 @@ data: ... """) +TEST_OVERRIDE_PASSPHRASES_CATALOG = yaml.safe_load( + """ +--- +schema: pegleg/PassphraseCatalog/v1 +metadata: + schema: metadata/Document/v1 + name: cluster-passphrases + layeringDefinition: + abstract: false + layer: site + storagePolicy: cleartext +data: + passphrases: + - description: 'short description of the passphrase' + document_name: ucp_keystone_admin_password + encrypted: true + length: 24 + - description: 'short description of the passphrase' + document_name: osh_cinder_password + encrypted: true + length: 25 + - description: 'short description of the passphrase' + document_name: osh_placement_password + encrypted: true + length: 32 +... +""") + TEST_GLOBAL_PASSPHRASES_CATALOG = yaml.safe_load( """ --- @@ -230,6 +258,10 @@ def test_generate_passphrases(*_): os.makedirs(os.path.join(_dir, 'cicd_site_repo'), exist_ok=True) PassphraseGenerator('cicd', _dir, 'test_author').generate() + passphrase_dir = os.path.join( + _dir, 'site', 'cicd', 'secrets', 'passphrases') + assert 6 == len(os.listdir(passphrase_dir)) + for passphrase in TEST_PASSPHRASES_CATALOG['data']['passphrases']: passphrase_file_name = '{}.yaml'.format(passphrase['document_name']) passphrase_file_path = os.path.join( @@ -281,6 +313,68 @@ def test_generate_passphrases_exception(capture): 'try again.'))) +@mock.patch.object( + util.definition, + 'documents_for_site', + autospec=True, + return_value=TEST_SITE_DOCUMENTS) +@mock.patch.object( + pegleg.config, + 'get_site_repo', + autospec=True, + return_value='cicd_site_repo') +@mock.patch.object( + util.definition, + 'site_files', + autospec=True, + return_value=[ + 'cicd_site_repo/site/cicd/passphrases/passphrase-catalog.yaml', + ]) +@mock.patch.dict( + os.environ, { + 'PEGLEG_PASSPHRASE': 'ytrr89erARAiPE34692iwUMvWqqBvC', + 'PEGLEG_SALT': 'MySecretSalt1234567890][' + }) +def test_generate_passphrases_with_overidden_passphrase_catalog(*_): + _dir = tempfile.mkdtemp() + os.makedirs(os.path.join(_dir, 'cicd_site_repo'), exist_ok=True) + PassphraseGenerator( + 'cicd', _dir, 'test_author', + [TEST_OVERRIDE_PASSPHRASES_CATALOG]).generate() + + passphrase_dir = os.path.join( + _dir, 'site', 'cicd', 'secrets', 'passphrases') + assert 3 == len(os.listdir(passphrase_dir)) + + for passphrase in TEST_OVERRIDE_PASSPHRASES_CATALOG['data']['passphrases']: + passphrase_file_name = '{}.yaml'.format(passphrase['document_name']) + passphrase_file_path = os.path.join( + _dir, 'site', 'cicd', 'secrets', 'passphrases', + passphrase_file_name) + assert os.path.isfile(passphrase_file_path) + with open(passphrase_file_path) as stream: + doc = yaml.safe_load(stream) + assert doc['schema'] == 'pegleg/PeglegManagedDocument/v1' + assert doc['metadata']['storagePolicy'] == 'cleartext' + assert 'encrypted' in doc['data'] + assert doc['data']['encrypted']['by'] == 'test_author' + assert 'generated' in doc['data'] + assert doc['data']['generated']['by'] == 'test_author' + assert 'managedDocument' in doc['data'] + assert doc['data']['managedDocument']['metadata'][ + 'storagePolicy'] == 'encrypted' + decrypted_passphrase = encryption.decrypt( + doc['data']['managedDocument']['data'], + os.environ['PEGLEG_PASSPHRASE'].encode(), + os.environ['PEGLEG_SALT'].encode()) + if passphrase_file_name == 'osh_placement_password.yaml': + assert len(decrypted_passphrase) == 32 + elif passphrase_file_name == 'osh_cinder_password.yaml': + assert len(decrypted_passphrase) == 25 + else: + assert len(decrypted_passphrase) == 24 + + @mock.patch.object( util.definition, 'documents_for_site',