From 9163ef08cad6605f4c06eaf1589fbb410cdc662a Mon Sep 17 00:00:00 2001
From: "Ian H. Pittwood" <pittwoodian@gmail.com>
Date: Wed, 20 Nov 2019 11:18:02 -0600
Subject: [PATCH] Add passphrase catalog override option

Adds an option to specify a passphrase catalog to override catalogs
discovered in the site repository. This allows the generation of a
specified subset of passphrases instead of the entire site's catalog.

Change-Id: I797107234292eea8ca788b7a94ed5e2c90566bf5
---
 doc/source/cli/cli.rst                        | 10 +-
 pegleg/cli.py                                 | 15 ++-
 .../engine/generators/passphrase_generator.py | 11 ++-
 pegleg/engine/secrets.py                      | 17 +++-
 .../unit/engine/test_generate_passphrases.py  | 94 +++++++++++++++++++
 5 files changed, 138 insertions(+), 9 deletions(-)

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``:
 
 <save_location>/site/<site_name>/secrets/passphrases/<passphrase_name.yaml>
 
+**-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',