@ -22,12 +22,16 @@ author:
- Ryan Conway ( @rylon )
version_added : " 2.7 "
requirements :
- C ( op ) 1 Password command line utility ( v0 .5 .1 ) . See U ( https : / / support .1 password . com / command - line / )
- C ( op ) 1 Password command line utility . See U ( https : / / support .1 password . com / command - line / )
notes :
- " Based on the C(onepassword) lookup plugin by Scott Buchanan <sbuchanan@ri.pn>. "
short_description : Fetch facts from 1 Password items
- This module stores potentially sensitive data from 1 Password as Ansible facts .
Facts are subject to caching if enabled , which means this data could be stored in clear text
on disk or in a database .
- Tested with C ( op ) version 0.5 .3
short_description : Gather items from 1 Password and set them as facts
description :
- M ( onepassword_facts ) wraps the C ( op ) command line utility to fetch data about one or more 1 password items and return as Ansible facts .
- M ( onepassword_facts ) wraps the C ( op ) command line utility to fetch data about one or more 1 P assword items and return as Ansible facts .
- A fatal error occurs if any of the items being searched for can not be found .
- Recommend using with the C ( no_log ) option to avoid logging the values of the secrets being retrieved .
options :
@ -53,22 +57,28 @@ options:
required : True
auto_login :
description :
- A dictionary containing authentication details . If this is set , M ( onepassword_facts ) will attempt to login to 1 password automatically .
- The required values can be stored in Ansible Vault , and passed to the module securely that way .
- A dictionary containing authentication details . If this is set , M ( onepassword_facts ) will attempt to sign in to 1 Password automatically .
- Without this option , you must have already logged in via the 1 Password CLI before running Ansible .
- It is B ( highly ) recommened to store 1 Password credentials in an Ansible Vault . Ensure that the key used to encrypt
the Ansible Vault is equal to or greater in strength than the 1 Password master password .
suboptions :
account :
subdomain :
description :
- 1 Password account name ( < account > .1 password . com ) .
- 1 Password subdomain name ( < subdomain > .1 password . com ) .
- If this is not specified , the most recent subdomain will be used .
username :
description :
- 1 Password username .
masterpassword :
- Only required for initial sign in .
master_password :
description :
- The master password for your user .
secretkey :
- The master password for your subdomain .
- This is always required when specifying C ( auto_login ) .
required : True
secret_key :
description :
- The secret key for your user .
- The secret key for your subdomain .
- Only required for initial sign in .
default : { }
required : False
cli_path :
@ -82,7 +92,7 @@ EXAMPLES = '''
- name : Get a password
onepassword_facts :
search_terms : My 1 Password item
delegate_to : local
delegate_to : local host
no_log : true # Don't want to log the secrets to the console!
# Gather secrets from 1Password, with more advanced search terms:
@ -93,8 +103,8 @@ EXAMPLES = '''
field : Custom field name # optional, defaults to 'password'
section : Custom section name # optional, defaults to 'None'
vault : Name of the vault # optional, only necessary if there is more than 1 Vault available
delegate_to : local
no_log : true # Don't want to log the secrets to the console!
delegate_to : local host
no_log : True # Don't want to log the secrets to the console!
# Gather secrets combining simple and advanced search terms to retrieve two items, one of which we fetch two
# fields. In the first 'password' is fetched, as a field name is not specified (default behaviour) and in the
@ -109,7 +119,7 @@ EXAMPLES = '''
section : Custom section name # optional, defaults to 'None'
vault : Name of the vault # optional, only necessary if there is more than 1 Vault available
- name : A 1 Password item with document attachment
delegate_to : local
delegate_to : local host
no_log : true # Don't want to log the secrets to the console!
'''
@ -136,20 +146,70 @@ import errno
import json
import os
import re
from subprocess import Popen , PIPE
from ansible . module_utils . _text import to_bytes , to_native
from ansible . module_utils . basic import AnsibleModule
class AnsibleModuleError ( Exception ) :
def __init__ ( self , results ) :
self . results = results
def __repr__ ( self ) :
return self . results
class OnePasswordFacts ( object ) :
def __init__ ( self ) :
self . cli_path = module . params . get ( ' cli_path ' )
self . config_file_path = ' ~/.op/config '
self . auto_login = module . params . get ( ' auto_login ' )
self . token = { }
self . logged_in = False
self . token = None
terms = module . params . get ( ' search_terms ' )
self . terms = self . parse_search_terms ( module . params . get ( ' search_terms ' ) )
self . terms = self . parse_search_terms ( terms )
def _run ( self , args , expected_rc = 0 , command_input = None , ignore_errors = False ) :
command = [ self . cli_path ] + args
p = Popen ( command , stdout = PIPE , stderr = PIPE , stdin = PIPE )
out , err = p . communicate ( input = command_input )
rc = p . wait ( )
if not ignore_errors and rc != expected_rc :
raise AnsibleModuleError ( to_native ( err ) )
return rc , out , err
def _parse_field ( self , data_json , item_id , field_name , section_title = None ) :
data = json . loads ( data_json )
if ( ' documentAttributes ' in data [ ' details ' ] ) :
# This is actually a document, let's fetch the document data instead!
document = self . _run ( [ " get " , " document " , data [ ' overview ' ] [ ' title ' ] ] )
return { ' document ' : document [ 0 ] . strip ( ) }
else :
# This is not a document, let's try to find the requested field
if section_title is None :
for field_data in data [ ' details ' ] . get ( ' fields ' , [ ] ) :
if field_data . get ( ' name ' ) . lower ( ) == field_name . lower ( ) :
return { field_name : field_data . get ( ' value ' , ' ' ) }
# Not found it yet, so now lets see if there are any sections defined
# and search through those for the field. If a section was given, we skip
# any non-matching sections, otherwise we search them all until we find the field.
for section_data in data [ ' details ' ] . get ( ' sections ' , [ ] ) :
if section_title is not None and section_title . lower ( ) != section_data [ ' title ' ] . lower ( ) :
continue
for field_data in section_data . get ( ' fields ' , [ ] ) :
if field_data . get ( ' t ' ) . lower ( ) == field_name . lower ( ) :
return { field_name : field_data . get ( ' v ' , ' ' ) }
# We will get here if the field could not be found in any section and the item wasn't a document to be downloaded.
optional_section_title = ' ' if section_title is None else " in the section ' %s ' " % section_title
module . fail_json ( msg = " Unable to find an item in 1Password named ' %s ' with the field ' %s ' %s . " % ( item_id , field_name , optional_section_title ) )
def parse_search_terms ( self , terms ) :
processed_terms = [ ]
@ -159,7 +219,7 @@ class OnePasswordFacts(object):
term = { ' name ' : term }
if ' name ' not in term :
module . fail_json ( msg = " Missing required ' name ' field from search term, got: ' %s ' " % str ( term ) )
module . fail_json ( msg = " Missing required ' name ' field from search term, got: ' %s ' " % to_native ( term ) )
term [ ' field ' ] = term . get ( ' field ' , ' password ' )
term [ ' section ' ] = term . get ( ' section ' , None )
@ -169,122 +229,110 @@ class OnePasswordFacts(object):
return processed_terms
def run ( self ) :
result = { }
self . assert_logged_in ( )
for term in self . terms :
value = self . get_field ( term [ ' name ' ] , term [ ' field ' ] , term [ ' section ' ] , term [ ' vault ' ] )
if term [ ' name ' ] in result :
# If we already have a result for this key, we have to append this result dictionary
# to the existing one. This is only applicable when there is a single item
# in 1Password which has two different fields, and we want to retrieve both of them.
result [ term [ ' name ' ] ] . update ( value )
else :
# If this is the first result for this key, simply set it.
result [ term [ ' name ' ] ] = value
return result
def assert_logged_in ( self ) :
try :
self . _run ( [ " get " , " account " ] )
except OSError as e :
if e . errno == errno . ENOENT :
module . fail_json ( msg = " 1Password CLI tool not installed in path ' %s ' : %s " % ( self . cli_path , e ) )
else :
module . fail_json ( msg = " 1Password CLI tool failed to execute at path ' %s ' : %s " % ( self . cli_path , e ) )
except Exception as e :
# 1Password's CLI doesn't seem to return different error codes, so we need to handle a few of the common
# error cases by searching via regex, so we can provide a clearer error message to the user.
if re . search ( " .*You are not currently signed in.* " , str ( e ) ) is not None :
if ( self . auto_login is not None ) :
try :
token = self . _run ( [
" signin " , " %s .1password.com " % self . auto_login [ ' account ' ] ,
self . auto_login [ ' username ' ] ,
self . auto_login [ ' secretkey ' ] ,
self . auto_login [ ' masterpassword ' ] ,
" --shorthand=ansible_ %s " % self . auto_login [ ' account ' ] ,
" --output=raw "
] )
self . token = { ' OP_SESSION_ansible_ %s ' % self . auto_login [ ' account ' ] : token [ 0 ] . strip ( ) }
except Exception as e :
module . fail_json ( msg = " Unable to automatically login to 1Password: %s " % e )
else :
module . fail_json ( msg = (
" Not logged into 1Password: please run ' %s signin ' first, or see the module docs for "
" how to configure for automatic login. " % self . cli_path )
)
def get_raw ( self , item_id , vault = None ) :
try :
args = [ " get " , " item " , item_id ]
if vault is not None :
args + = [ ' --vault= {0} ' . format ( vault ) ]
output , dummy = self . _run ( args )
if not self . logged_in :
args + = [ to_bytes ( ' --session= ' ) + self . token ]
rc , output , dummy = self . _run ( args )
return output
except Exception as e :
if re . search ( " .*not found.* " , str ( e ) ) :
if re . search ( " .*not found.* " , to_native ( e ) ) :
module . fail_json ( msg = " Unable to find an item in 1Password named ' %s ' . " % item_id )
else :
module . fail_json ( msg = " Unexpected error attempting to find an item in 1Password named ' %s ' : %s " % ( item_id , e) )
module . fail_json ( msg = " Unexpected error attempting to find an item in 1Password named ' %s ' : %s " % ( item_id , to_native ( e ) ) )
def get_field ( self , item_id , field , section = None , vault = None ) :
output = self . get_raw ( item_id , vault )
return self . _parse_field ( output , item_id , field , section ) if output != ' ' else ' '
def _run ( self , args , expected_rc = 0 ) :
# Duplicates the current shell environment before running 'op', so we get the same PATH the user has,
# but we merge in the auth token dictionary, allowing the auto-login functionality to work (if enabled).
env = { }
env . update ( os . environ . copy ( ) )
env . update ( self . token )
def full_login ( self ) :
if self . auto_login is not None :
if None in [ self . auto_login . get ( ' subdomain ' ) , self . auto_login . get ( ' username ' ) ,
self . auto_login . get ( ' secret_key ' ) , self . auto_login . get ( ' master_password ' ) ] :
module . fail_json ( msg = ' Unable to perform initial sign in to 1Password. '
' subdomain, username, secret_key, and master_password are required to perform initial sign in. ' )
args = [
' signin ' ,
' {0} .1password.com ' . format ( self . auto_login [ ' subdomain ' ] ) ,
to_bytes ( self . auto_login [ ' username ' ] ) ,
to_bytes ( self . auto_login [ ' secret_key ' ] ) ,
' --output=raw ' ,
]
p = Popen ( [ self . cli_path ] + args , stdout = PIPE , stderr = PIPE , stdin = PIPE , env = env )
out , err = p . communicate ( )
try :
rc , out , err = self . _run ( args , command_input = to_bytes ( self . auto_login [ ' master_password ' ] ) )
self . token = out . strip ( )
except AnsibleModuleError as e :
module . fail_json ( msg = " Failed to perform initial sign in to 1Password: %s " % to_native ( e ) )
else :
module . fail_json ( msg = " Unable to perform an initial sign in to 1Password. Please run ' %s sigin ' "
" or define credentials in ' auto_login ' . See the module documentation for details. " % self . cli_path )
rc = p . wait ( )
def get_token ( self ) :
# If the config file exists, assume an initial signin has taken place and try basic sign in
if os . path . isfile ( self . config_file_path ) :
if rc != expected_rc :
raise Exception ( err )
if self . auto_login is not None :
return out , err
# Since we are not currently signed in, master_password is required at a minimum
if not self . auto_login . get ( ' master_password ' ) :
module . fail_json ( msg = " Unable to sign in to 1Password. ' auto_login.master_password ' is required. " )
def _parse_field ( self , data_json , item_id , field_name , section_title = None ) :
data = json . loads ( data_json )
# Try signing in using the master_password and a subdomain if one is provided
try :
args = [ ' signin ' , ' --output=raw ' ]
if ( ' documentAttributes ' in data [ ' details ' ] ) :
# This is actually a document, let's fetch the document data instead!
document = self . _run ( [ " get " , " document " , data [ ' overview ' ] [ ' title ' ] ] )
return { ' document ' : document [ 0 ] . strip ( ) }
if self . auto_login . get ( ' subdomain ' ) :
args = [ ' signin ' , self . auto_login [ ' subdomain ' ] , ' --output=raw ' ]
rc , out , err = self . _run ( args , command_input = to_bytes ( self . auto_login [ ' master_password ' ] ) )
self . token = out . strip ( )
except AnsibleModuleError :
self . full_login ( )
else :
# This is not a document, let's try to find the requested field
if section_title is None :
for field_data in data [ ' details ' ] . get ( ' fields ' , [ ] ) :
if field_data . get ( ' name ' ) . lower ( ) == field_name . lower ( ) :
return { field_name : field_data . get ( ' value ' , ' ' ) }
self . full_login ( )
# Not found it yet, so now lets see if there are any sections defined
# and search through those for the field. If a section was given, we skip
# any non-matching sections, otherwise we search them all until we find the field.
for section_data in data [ ' details ' ] . get ( ' sections ' , [ ] ) :
if section_title is not None and section_title . lower ( ) != section_data [ ' title ' ] . lower ( ) :
continue
for field_data in section_data . get ( ' fields ' , [ ] ) :
if field_data . get ( ' t ' ) . lower ( ) == field_name . lower ( ) :
return { field_name : field_data . get ( ' v ' , ' ' ) }
else :
# Attempt a full sign in since there appears to be no existing sign in
self . full_login ( )
# We will get here if the field could not be found in any section and the item wasn't a document to be downloaded.
optional_section_title = ' ' if section_title is None else " in the section ' %s ' " % section_title
module . fail_json ( msg = " Unable to find an item in 1Password named ' %s ' with the field ' %s ' %s . " % ( item_id , field_name , optional_section_title ) )
def assert_logged_in ( self ) :
try :
rc , out , err = self . _run ( [ ' get ' , ' account ' ] , ignore_errors = True )
if rc == 0 :
self . logged_in = True
if not self . logged_in :
self . get_token ( )
except OSError as e :
if e . errno == errno . ENOENT :
module . fail_json ( msg = " 1Password CLI tool ' %s ' not installed in path on control machine " % self . cli_path )
raise e
def run ( self ) :
result = { }
self . assert_logged_in ( )
for term in self . terms :
value = self . get_field ( term [ ' name ' ] , term [ ' field ' ] , term [ ' section ' ] , term [ ' vault ' ] )
if term [ ' name ' ] in result :
# If we already have a result for this key, we have to append this result dictionary
# to the existing one. This is only applicable when there is a single item
# in 1Password which has two different fields, and we want to retrieve both of them.
result [ term [ ' name ' ] ] . update ( value )
else :
# If this is the first result for this key, simply set it.
result [ term [ ' name ' ] ] = value
return result
def main ( ) :
@ -293,10 +341,10 @@ def main():
argument_spec = dict (
cli_path = dict ( type = ' path ' , default = ' op ' ) ,
auto_login = dict ( type = ' dict ' , options = dict (
account= dict ( required = True , type = ' str ' ) ,
username = dict ( required = True , type = ' str ' ) ,
master password= dict ( required = True , type = ' str ' ) ,
secret key= dict ( required = True , type = ' str ' ) ,
subdomain= dict ( type = ' str ' ) ,
username = dict ( type = ' str ' ) ,
master _ password= dict ( required = True , type = ' str ' , no_log = True ) ,
secret _ key= dict ( type = ' str ' , no_log = True ) ,
) , default = None ) ,
search_terms = dict ( required = True , type = ' list ' )
) ,