move path parameterization to the CredentialInputSource model

This commit is contained in:
Ryan Petrello
2019-02-25 13:09:34 -05:00
committed by Jake McDermott
parent 0ee223f799
commit 69368d874e
10 changed files with 337 additions and 58 deletions

View File

@@ -2833,6 +2833,7 @@ class CredentialInputSourceSerializer(BaseSerializer):
'type',
'url',
'input_field_name',
'metadata',
'target_credential',
'source_credential',
'source_credential_type',

View File

@@ -22,7 +22,8 @@ azure_keyvault_inputs = {
'id': 'tenant',
'label': 'Tenant ID',
'type': 'string'
}, {
}],
'metadata': [{
'id': 'secret_field',
'label': 'Secret Name',
'type': 'string',
@@ -33,7 +34,7 @@ azure_keyvault_inputs = {
'type': 'string',
'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).',
}],
'required': ['url', 'client', 'secret', 'tenant'],
'required': ['url', 'client', 'secret', 'tenant', 'secret_field'],
}

View File

@@ -1,3 +1,4 @@
import copy
import os
import pathlib
@@ -18,7 +19,8 @@ base_inputs = {
'type': 'string',
'secret': True,
'help_text': 'The access token used to authenticate to the Vault server',
}, {
}],
'metadata': [{
'id': 'secret_path',
'label': 'Path to Secret',
'type': 'string',
@@ -27,50 +29,49 @@ base_inputs = {
'required': ['url', 'token', 'secret_path'],
}
hashi_kv_inputs = {
'fields': base_inputs['fields'] + [{
'id': 'secret_field',
'label': 'Key Name',
'type': 'string',
'help_text': 'The name of the key to look up in the secret.',
}, {
'id': 'secret_version',
'label': 'Secret Version (v2 only)',
'type': 'string',
'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).',
}, {
'id': 'api_version',
'label': 'API Version',
'choices': ['v1', 'v2'],
'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.',
'default': 'v1',
}],
'required': base_inputs['required'] + ['secret_field', 'api_version']
}
hashi_kv_inputs = copy.deepcopy(base_inputs)
hashi_kv_inputs['fields'].append({
'id': 'api_version',
'label': 'API Version',
'choices': ['v1', 'v2'],
'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.',
'default': 'v1',
})
hashi_kv_inputs['metadata'].extend([{
'id': 'secret_key',
'label': 'Key Name',
'type': 'string',
'help_text': 'The name of the key to look up in the secret.',
}, {
'id': 'secret_version',
'label': 'Secret Version (v2 only)',
'type': 'string',
'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).',
}])
hashi_kv_inputs['required'].extend(['api_version', 'secret_key'])
hashi_ssh_inputs = {
'fields': base_inputs['fields'] + [{
'id': 'role',
'label': 'Role Name',
'type': 'string',
'help_text': 'The name of the role used to sign.'
}, {
'id': 'valid_principals',
'label': 'Valid Principals',
'type': 'string',
'help_text': 'Valid principals (either usernames or hostnames) that the certificate should be signed for.',
}],
'required': base_inputs['required'] + ['role']
}
hashi_ssh_inputs = copy.deepcopy(base_inputs)
hashi_ssh_inputs['metadata'].extend([{
'id': 'role',
'label': 'Role Name',
'type': 'string',
'help_text': 'The name of the role used to sign.'
}, {
'id': 'valid_principals',
'label': 'Valid Principals',
'type': 'string',
'help_text': 'Valid principals (either usernames or hostnames) that the certificate should be signed for.',
}])
hashi_ssh_inputs['required'].extend(['role'])
def kv_backend(raw, **kwargs):
token = kwargs['token']
url = kwargs['url']
secret_path = kwargs['secret_path']
secret_field = kwargs.get('secret_field', None)
secret_key = kwargs.get('secret_key', None)
api_version = kwargs.get('api_version', None)
api_version = kwargs['api_version']
client = Client(url=url, token=token, verify=True)
if api_version == 'v2':
@@ -99,12 +100,12 @@ def kv_backend(raw, **kwargs):
'could not read secret {} from {}'.format(secret_path, url)
)
if secret_field:
if secret_key:
try:
return response['data'][secret_field]
return response['data'][secret_key]
except KeyError:
raise RuntimeError(
'{} is not present at {}'.format(secret_field, secret_path)
'{} is not present at {}'.format(secret_key, secret_path)
)
return response['data']

View File

@@ -480,6 +480,69 @@ def format_ssh_private_key(value):
return True
class DynamicCredentialInputField(JSONSchemaField):
"""
Used to validate JSON for
`awx.main.models.credential:CredentialInputSource().metadata`.
Metadata for input sources is represented as a dictionary e.g.,
{'secret_path': '/kv/somebody', 'secret_key': 'password'}
For the data to be valid, the keys of this dictionary should correspond
with the metadata field (and datatypes) defined in the associated
target CredentialType e.g.,
"""
def schema(self, credential_type):
# determine the defined fields for the associated credential type
properties = {}
for field in credential_type.inputs.get('metadata', []):
field = field.copy()
properties[field['id']] = field
if field.get('choices', []):
field['enum'] = list(field['choices'])[:]
return {
'type': 'object',
'properties': properties,
'additionalProperties': False,
}
def validate(self, value, model_instance):
if not isinstance(value, dict):
return super(DynamicCredentialInputField, self).validate(value, model_instance)
super(JSONSchemaField, self).validate(value, model_instance)
credential_type = model_instance.source_credential.credential_type
errors = {}
for error in Draft4Validator(
self.schema(credential_type),
format_checker=self.format_checker
).iter_errors(value):
if error.validator == 'pattern' and 'error' in error.schema:
error.message = error.schema['error'].format(instance=error.instance)
if 'id' not in error.schema:
# If the error is not for a specific field, it's specific to
# `inputs` in general
raise django_exceptions.ValidationError(
error.message,
code='invalid',
params={'value': value},
)
errors[error.schema['id']] = [error.message]
defined_metadata = [field.get('id') for field in credential_type.inputs.get('metadata', [])]
for field in credential_type.inputs.get('required', []):
if field in defined_metadata and not value.get(field, None):
errors[field] = [_('required for %s') % (
credential_type.name
)]
if errors:
raise serializers.ValidationError({
'metadata': errors
})
class CredentialInputField(JSONSchemaField):
"""
Used to validate JSON for
@@ -593,8 +656,9 @@ class CredentialInputField(JSONSchemaField):
errors[error.schema['id']] = [error.message]
inputs = model_instance.credential_type.inputs
defined_fields = model_instance.credential_type.defined_fields
for field in inputs.get('required', []):
if not value.get(field, None):
if field in defined_fields and not value.get(field, None):
errors[field] = [_('required for %s') % (
model_instance.credential_type.name
)]
@@ -603,7 +667,7 @@ class CredentialInputField(JSONSchemaField):
# represented without complicated JSON schema
if (
model_instance.credential_type.managed_by_tower is True and
'ssh_key_unlock' in model_instance.credential_type.defined_fields
'ssh_key_unlock' in defined_fields
):
# in order to properly test the necessity of `ssh_key_unlock`, we

View File

@@ -7,6 +7,7 @@ import django.db.models.deletion
import taggit.managers
# AWX
import awx.main.fields
from awx.main.models import CredentialType
@@ -31,6 +32,7 @@ class Migration(migrations.Migration):
('modified', models.DateTimeField(default=None, editable=False)),
('description', models.TextField(blank=True, default='')),
('input_field_name', models.CharField(max_length=1024)),
('metadata', awx.main.fields.DynamicCredentialInputField(blank=True, default={})),
('created_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_created+", to=settings.AUTH_USER_MODEL)),
('modified_by', models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{'class': 'credentialinputsource', 'model_name': 'credentialinputsource', 'app_label': 'main'}(class)s_modified+", to=settings.AUTH_USER_MODEL)),
('source_credential', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_input_source', to='main.Credential')),

View File

@@ -23,7 +23,8 @@ from django.utils.encoding import force_text
from awx.api.versioning import reverse
from awx.main.fields import (ImplicitRoleField, CredentialInputField,
CredentialTypeInputField,
CredentialTypeInjectorField)
CredentialTypeInjectorField,
DynamicCredentialInputField,)
from awx.main.utils import decrypt_field, classproperty
from awx.main.utils.safe_yaml import safe_dump
from awx.main.validators import validate_ssh_private_key
@@ -1325,6 +1326,10 @@ class CredentialInputSource(PrimordialModel):
input_field_name = models.CharField(
max_length=1024,
)
metadata = DynamicCredentialInputField(
blank=True,
default={}
)
def clean_target_credential(self):
if self.target_credential.kind == 'external':
@@ -1353,6 +1358,8 @@ class CredentialInputSource(PrimordialModel):
backend_kwargs[field_name] = decrypt_field(self.source_credential, field_name)
else:
backend_kwargs[field_name] = value
backend_kwargs.update(self.metadata)
return backend(
self.target_credential.inputs.get(self.input_field_name),
**backend_kwargs

View File

@@ -1283,8 +1283,8 @@ class RunJob(BaseTask):
...
},
'certificates': {
<awx.main.models.Credential>: <signed SSH certifacte data>,
<awx.main.models.Credential>: <signed SSH certifacte data>,
<awx.main.models.Credential>: <signed SSH certificate data>,
<awx.main.models.Credential>: <signed SSH certificate data>,
...
}
}
@@ -2208,8 +2208,8 @@ class RunAdHocCommand(BaseTask):
...
},
'certificates': {
<awx.main.models.Credential>: <signed SSH certifacte data>,
<awx.main.models.Credential>: <signed SSH certifacte data>,
<awx.main.models.Credential>: <signed SSH certificate data>,
<awx.main.models.Credential>: <signed SSH certificate data>,
...
}
}

View File

@@ -15,6 +15,7 @@ def test_associate_credential_input_source(get, post, admin, vault_credential, e
params = {
'source_credential': external_credential.pk,
'input_field_name': 'vault_password',
'metadata': {'key': 'some_example_key'},
'associate': True
}
response = post(sublist_url, params, admin)
@@ -31,6 +32,8 @@ def test_associate_credential_input_source(get, post, admin, vault_credential, e
kwargs={'version': 'v2'}
), admin).data['count'] == 1
assert CredentialInputSource.objects.count() == 1
input_source = CredentialInputSource.objects.first()
assert input_source.metadata == {'key': 'some_example_key'}
# detach
params = {
@@ -50,6 +53,29 @@ def test_associate_credential_input_source(get, post, admin, vault_credential, e
assert CredentialInputSource.objects.count() == 0
@pytest.mark.django_db
@pytest.mark.parametrize('metadata', [
{}, # key is required
{'key': None}, # must be a string
{'key': 123}, # must be a string
{'extraneous': 'foo'}, # invalid parameter
])
def test_associate_credential_input_source_with_invalid_metadata(get, post, admin, vault_credential, external_credential, metadata):
sublist_url = reverse(
'api:credential_input_source_sublist',
kwargs={'version': 'v2', 'pk': vault_credential.pk}
)
params = {
'source_credential': external_credential.pk,
'input_field_name': 'vault_password',
'metadata': metadata,
'associate': True
}
response = post(sublist_url, params, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_cannot_create_from_list(get, post, admin, vault_credential, external_credential):
params = {
@@ -73,6 +99,7 @@ def test_create_credential_input_source_with_external_target_returns_400(post, a
'source_credential': external_credential.pk,
'input_field_name': 'token',
'associate': True,
'metadata': {'key': 'some_key'},
}
response = post(sublist_url, params, admin)
assert response.status_code == 400
@@ -102,7 +129,8 @@ def test_create_credential_input_source_with_undefined_input_returns_400(post, a
)
params = {
'source_credential': external_credential.pk,
'input_field_name': 'not_defined_for_credential_type'
'input_field_name': 'not_defined_for_credential_type',
'metadata': {'key': 'some_key'}
}
response = post(sublist_url, params, admin)
assert response.status_code == 400

View File

@@ -265,7 +265,16 @@ def credentialtype_external():
'secret': True,
'help_text': 'An access token for the server.'
}],
'required': ['url', 'token'],
'metadata': [{
'id': 'key',
'label': 'Key',
'type': 'string'
}, {
'id': 'version',
'label': 'Version',
'type': 'string'
}],
'required': ['url', 'token', 'key'],
}
external_type = CredentialType(
kind='external',