Repository: ansible/ansible

Issue:
Cannot use ansible-vault encrypt together with vault_identity_list in ~/.ansible.cfg
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
ansible-vault encrypt

##### ANSIBLE VERSION
```
ansible 2.4.0.0
  config file = /home/hjudt/.ansible.cfg
  configured module search path = ['/home/hjudt/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib64/python3.5/site-packages/ansible
  executable location = /usr/lib/python-exec/python3.5/ansible
  python version = 3.5.3 (default, Sep 20 2017, 22:40:40) [GCC 6.4.0]
```

##### CONFIGURATION
ANSIBLE_SSH_PIPELINING(/home/user/.ansible.cfg) = True
DEFAULT_VAULT_IDENTITY_LIST(/home/user/.ansible.cfg) = ['prod@/home/user/ansible/vault/prod', 'test@/home/user/ansible/vault/test', 'dev@/home/user/ansible/vault/dev', 'staging@/home/user/ansible/vault/staging']

##### OS / ENVIRONMENT
Linux/x64 gentoo

##### SUMMARY
When using vault_identity_list in ~/.ansible.cfg causes ansible-vault encrypt refusing to do any work, complaining "ERROR! Only one --vault-id can be used for encryption".

##### STEPS TO REPRODUCE
1. add setting (see example above) to ~/.ansible.cfg.
2a. try to use ansible-vault encrypt --vault-id <id>@passwordfile file-to-encrypt and see it fail
2b. try to use ansible-vault encrypt file-to-encrypt and see it fail

But: Decrypting works flawlessly. Also, if you remove the line in the config and use the --vault-id command line parameter, encryption works without any issues.

##### EXPECTED RESULTS
File to be encrypted.

##### ACTUAL RESULTS
Ansible-vault encrypt complains: "ERROR! Only one --vault-id can be used for encryption"



Discussion:

Resolving patch:
diff --git a/lib/ansible/cli/__init__.py b/lib/ansible/cli/__init__.py
--- a/lib/ansible/cli/__init__.py
+++ b/lib/ansible/cli/__init__.py
@@ -367,7 +367,7 @@ def _dep(which):
         if self.options.ask_su_pass or self.options.su_user:
             _dep('su')
 
-    def validate_conflicts(self, vault_opts=False, runas_opts=False, fork_opts=False):
+    def validate_conflicts(self, vault_opts=False, runas_opts=False, fork_opts=False, vault_rekey_opts=False):
         ''' check for conflicting options '''
 
         op = self.options
@@ -377,6 +377,10 @@ def validate_conflicts(self, vault_opts=False, runas_opts=False, fork_opts=False
             if (op.ask_vault_pass and op.vault_password_files):
                 self.parser.error("--ask-vault-pass and --vault-password-file are mutually exclusive")
 
+        if vault_rekey_opts:
+            if (op.new_vault_id and op.new_vault_password_file):
+                self.parser.error("--new-vault-password-file and --new-vault-id are mutually exclusive")
+
         if runas_opts:
             # Check for privilege escalation conflicts
             if ((op.su or op.su_user) and (op.sudo or op.sudo_user) or
@@ -452,8 +456,8 @@ def base_parser(usage="", output_opts=False, runas_opts=False, meta_opts=False,
                               help='the vault identity to use')
 
         if vault_rekey_opts:
-            parser.add_option('--new-vault-password-file', default=[], dest='new_vault_password_files',
-                              help="new vault password file for rekey", action="callback", callback=CLI.unfrack_paths, type='string')
+            parser.add_option('--new-vault-password-file', default=None, dest='new_vault_password_file',
+                              help="new vault password file for rekey", action="callback", callback=CLI.unfrack_path, type='string')
             parser.add_option('--new-vault-id', default=None, dest='new_vault_id', type='string',
                               help='the new vault identity to use for rekey')
 
diff --git a/lib/ansible/cli/vault.py b/lib/ansible/cli/vault.py
--- a/lib/ansible/cli/vault.py
+++ b/lib/ansible/cli/vault.py
@@ -106,6 +106,12 @@ def set_action(self):
         elif self.action == "rekey":
             self.parser.set_usage("usage: %prog rekey [options] file_name")
 
+        # For encrypting actions, we can also specify which of multiple vault ids should be used for encrypting
+        if self.action in ['create', 'encrypt', 'encrypt_string', 'rekey']:
+            self.parser.add_option('--encrypt-vault-id', default=[], dest='encrypt_vault_id',
+                                   action='store', type='string',
+                                   help='the vault id used to encrypt (required if more than vault-id is provided)')
+
     def parse(self):
 
         self.parser = CLI.base_parser(
@@ -119,6 +125,7 @@ def parse(self):
         self.set_action()
 
         super(VaultCLI, self).parse()
+        self.validate_conflicts(vault_opts=True, vault_rekey_opts=True)
 
         display.verbosity = self.options.verbosity
 
@@ -174,9 +181,12 @@ def run(self):
             if not vault_secrets:
                 raise AnsibleOptionsError("A vault password is required to use Ansible's Vault")
 
-        if self.action in ['encrypt', 'encrypt_string', 'create']:
-            if len(vault_ids) > 1:
-                raise AnsibleOptionsError("Only one --vault-id can be used for encryption")
+        if self.action in ['encrypt', 'encrypt_string', 'create', 'edit']:
+
+            encrypt_vault_id = None
+            # no --encrypt-vault-id self.options.encrypt_vault_id for 'edit'
+            if self.action not in ['edit']:
+                encrypt_vault_id = self.options.encrypt_vault_id or C.DEFAULT_VAULT_ENCRYPT_IDENTITY
 
             vault_secrets = None
             vault_secrets = \
@@ -186,36 +196,52 @@ def run(self):
                                          ask_vault_pass=self.options.ask_vault_pass,
                                          create_new_password=True)
 
-            if len(vault_secrets) > 1:
-                raise AnsibleOptionsError("Only one --vault-id can be used for encryption. This includes passwords from configuration and cli.")
+            if len(vault_secrets) > 1 and not encrypt_vault_id:
+                raise AnsibleOptionsError("The vault-ids %s are available to encrypt. Specify the vault-id to encrypt with --encrypt-vault-id" %
+                                          ','.join([x[0] for x in vault_secrets]))
 
             if not vault_secrets:
                 raise AnsibleOptionsError("A vault password is required to use Ansible's Vault")
 
-            encrypt_secret = match_encrypt_secret(vault_secrets)
+            encrypt_secret = match_encrypt_secret(vault_secrets,
+                                                  encrypt_vault_id=encrypt_vault_id)
+
             # only one secret for encrypt for now, use the first vault_id and use its first secret
-            # self.encrypt_vault_id = list(vault_secrets.keys())[0]
-            # self.encrypt_secret = vault_secrets[self.encrypt_vault_id][0]
+            # TODO: exception if more than one?
             self.encrypt_vault_id = encrypt_secret[0]
             self.encrypt_secret = encrypt_secret[1]
 
         if self.action in ['rekey']:
+            encrypt_vault_id = self.options.encrypt_vault_id or C.DEFAULT_VAULT_ENCRYPT_IDENTITY
+            # print('encrypt_vault_id: %s' % encrypt_vault_id)
+            # print('default_encrypt_vault_id: %s' % default_encrypt_vault_id)
+
+            # new_vault_ids should only ever be one item, from
+            # load the default vault ids if we are using encrypt-vault-id
             new_vault_ids = []
+            if encrypt_vault_id:
+                new_vault_ids = default_vault_ids
             if self.options.new_vault_id:
                 new_vault_ids.append(self.options.new_vault_id)
 
+            new_vault_password_files = []
+            if self.options.new_vault_password_file:
+                new_vault_password_files.append(self.options.new_vault_password_file)
+
             new_vault_secrets = \
                 self.setup_vault_secrets(loader,
                                          vault_ids=new_vault_ids,
-                                         vault_password_files=self.options.new_vault_password_files,
+                                         vault_password_files=new_vault_password_files,
                                          ask_vault_pass=self.options.ask_vault_pass,
                                          create_new_password=True)
 
             if not new_vault_secrets:
                 raise AnsibleOptionsError("A new vault password is required to use Ansible's Vault rekey")
 
-            # There is only one new_vault_id currently and one new_vault_secret
-            new_encrypt_secret = match_encrypt_secret(new_vault_secrets)
+            # There is only one new_vault_id currently and one new_vault_secret, or we
+            # use the id specified in --encrypt-vault-id
+            new_encrypt_secret = match_encrypt_secret(new_vault_secrets,
+                                                      encrypt_vault_id=encrypt_vault_id)
 
             self.new_encrypt_vault_id = new_encrypt_secret[0]
             self.new_encrypt_secret = new_encrypt_secret[1]
diff --git a/lib/ansible/parsing/vault/__init__.py b/lib/ansible/parsing/vault/__init__.py
--- a/lib/ansible/parsing/vault/__init__.py
+++ b/lib/ansible/parsing/vault/__init__.py
@@ -556,12 +556,40 @@ def match_best_secret(secrets, target_vault_ids):
     return None
 
 
-def match_encrypt_secret(secrets):
+def match_encrypt_vault_id_secret(secrets, encrypt_vault_id=None):
+    # See if the --encrypt-vault-id matches a vault-id
+    display.vvvv('encrypt_vault_id=%s' % encrypt_vault_id)
+
+    if encrypt_vault_id is None:
+        raise AnsibleError('match_encrypt_vault_id_secret requires a non None encrypt_vault_id')
+
+    encrypt_vault_id_matchers = [encrypt_vault_id]
+    encrypt_secret = match_best_secret(secrets, encrypt_vault_id_matchers)
+
+    # return the best match for --encrypt-vault-id
+    if encrypt_secret:
+        return encrypt_secret
+
+    # If we specified a encrypt_vault_id and we couldn't find it, dont
+    # fallback to using the first/best secret
+    raise AnsibleVaultError('Did not find a match for --encrypt-vault-id=%s in the known vault-ids %s' % (encrypt_vault_id,
+                                                                                                          [_v for _v, _vs in secrets]))
+
+
+def match_encrypt_secret(secrets, encrypt_vault_id=None):
     '''Find the best/first/only secret in secrets to use for encrypting'''
 
+    display.vvvv('encrypt_vault_id=%s' % encrypt_vault_id)
+    # See if the --encrypt-vault-id matches a vault-id
+    if encrypt_vault_id:
+        return match_encrypt_vault_id_secret(secrets,
+                                             encrypt_vault_id=encrypt_vault_id)
+
+    # Find the best/first secret from secrets since we didnt specify otherwise
     # ie, consider all of the available secrets as matches
     _vault_id_matchers = [_vault_id for _vault_id, dummy in secrets]
     best_secret = match_best_secret(secrets, _vault_id_matchers)
+
     # can be empty list sans any tuple
     return best_secret
 
@@ -625,7 +653,11 @@ def encrypt(self, plaintext, secret=None, vault_id=None):
             raise AnsibleError(u"{0} cipher could not be found".format(self.cipher_name))
 
         # encrypt data
-        display.vvvvv('Encrypting with vault secret %s' % secret)
+        if vault_id:
+            display.vvvvv('Encrypting with vault_id "%s" and vault secret %s' % (vault_id, secret))
+        else:
+            display.vvvvv('Encrypting without a vault_id using vault secret %s' % secret)
+
         b_ciphertext = this_cipher.encrypt(b_plaintext, secret)
 
         # format the data for output to the file
@@ -725,7 +757,10 @@ def decrypt_and_get_vault_id(self, vaulttext, filename=None):
                 b_plaintext = this_cipher.decrypt(b_vaulttext, vault_secret)
                 if b_plaintext is not None:
                     vault_id_used = vault_secret_id
-                    display.vvvvv('decrypt successful with secret=%s and vault_id=%s' % (vault_secret, vault_secret_id))
+                    file_slug = ''
+                    if filename:
+                        file_slug = ' of "%s"' % filename
+                    display.vvvvv('Decrypt%s successful with secret=%s and vault_id=%s' % (file_slug, vault_secret, vault_secret_id))
                     break
             except AnsibleVaultFormatError as exc:
                 msg = "There was a vault format error"
@@ -963,7 +998,7 @@ def plaintext(self, filename):
         vaulttext = to_text(b_vaulttext)
 
         try:
-            plaintext = self.vault.decrypt(vaulttext)
+            plaintext = self.vault.decrypt(vaulttext, filename=filename)
             return plaintext
         except AnsibleError as e:
             raise AnsibleVaultError("%s for %s" % (to_bytes(e), to_bytes(filename)))
@@ -978,8 +1013,10 @@ def rekey_file(self, filename, new_vault_secret, new_vault_id=None):
         b_vaulttext = self.read_data(filename)
         vaulttext = to_text(b_vaulttext)
 
+        display.vvvvv('Rekeying file "%s" to with new vault-id "%s" and vault secret %s' %
+                      (filename, new_vault_id, new_vault_secret))
         try:
-            plaintext = self.vault.decrypt(vaulttext)
+            plaintext, vault_id_used = self.vault.decrypt_and_get_vault_id(vaulttext)
         except AnsibleError as e:
             raise AnsibleError("%s for %s" % (to_bytes(e), to_bytes(filename)))
 
@@ -1004,6 +1041,9 @@ def rekey_file(self, filename, new_vault_secret, new_vault_id=None):
         os.chmod(filename, prev.st_mode)
         os.chown(filename, prev.st_uid, prev.st_gid)
 
+        display.vvvvv('Rekeyed file "%s" (decrypted with vault id "%s") was encrypted with new vault-id "%s" and vault secret %s' %
+                      (filename, vault_id_used, new_vault_id, new_vault_secret))
+
     def read_data(self, filename):
 
         try:


Test patch:
diff --git a/test/integration/targets/vault/runme.sh b/test/integration/targets/vault/runme.sh
--- a/test/integration/targets/vault/runme.sh
+++ b/test/integration/targets/vault/runme.sh
@@ -185,6 +185,13 @@ WRONG_RC=$?
 echo "rc was $WRONG_RC (1 is expected)"
 [ $WRONG_RC -eq 1 ]
 
+# try specifying a --encrypt-vault-id that doesnt exist, should exit with an error indicating
+# that --encrypt-vault-id and the known vault-ids
+ansible-vault encrypt "$@" --vault-password-file vault-password --encrypt-vault-id doesnt_exist "${TEST_FILE}" && :
+WRONG_RC=$?
+echo "rc was $WRONG_RC (1 is expected)"
+[ $WRONG_RC -eq 1 ]
+
 # encrypt it
 ansible-vault encrypt "$@" --vault-password-file vault-password "${TEST_FILE}"
 
@@ -252,6 +259,12 @@ ansible-vault encrypt "$@" --vault-password-file vault-password "${TEST_FILE}"
 
 ansible-vault rekey "$@" --vault-password-file vault-password --new-vault-password-file "${NEW_VAULT_PASSWORD}" "${TEST_FILE}"
 
+# --new-vault-password-file and --new-vault-id should cause options error
+ansible-vault rekey "$@" --vault-password-file vault-password --new-vault-id=foobar --new-vault-password-file "${NEW_VAULT_PASSWORD}" "${TEST_FILE}" && :
+WRONG_RC=$?
+echo "rc was $WRONG_RC (2 is expected)"
+[ $WRONG_RC -eq 2 ]
+
 ansible-vault view "$@" --vault-password-file "${NEW_VAULT_PASSWORD}" "${TEST_FILE}"
 
 # view with old password file and new password file