diff --git a/awx/main/migrations/0039_v320_add_credentialtype_model.py b/awx/main/migrations/0039_v320_add_credentialtype_model.py new file mode 100644 index 0000000000..3d4dd8e345 --- /dev/null +++ b/awx/main/migrations/0039_v320_add_credentialtype_model.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings +import taggit.managers +import awx.main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0038_v320_data_migrations'), + ] + + operations = [ + migrations.CreateModel( + name='CredentialType', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(default=b'', blank=True)), + ('name', models.CharField(max_length=512)), + ('kind', models.CharField(max_length=32, choices=[(b'machine', 'Machine'), (b'net', 'Network'), (b'scm', 'Source Control'), (b'cloud', 'Cloud')])), + ('managed_by_tower', models.BooleanField(default=False, editable=False)), + ('inputs', awx.main.fields.CredentialTypeInputField(default={}, blank=True)), + ('injectors', awx.main.fields.CredentialTypeInjectorField(default={}, blank=True)), + ('created_by', models.ForeignKey(related_name="{u'class': 'credentialtype', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('modified_by', models.ForeignKey(related_name="{u'class': 'credentialtype', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), + ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), + ], + options={ + 'ordering': ('kind', 'name'), + }, + ), + migrations.AlterModelOptions( + name='credential', + options={'ordering': ('name',)}, + ), + migrations.AddField( + model_name='credential', + name='inputs', + field=awx.main.fields.CredentialInputField(default={}, blank=True), + ), + migrations.AddField( + model_name='credential', + name='credential_type', + field=models.ForeignKey(related_name='credentials', to='main.CredentialType', null=True), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='credential', + unique_together=set([('organization', 'name', 'credential_type')]), + ), + ] diff --git a/awx/main/migrations/0039_v320_migrate_credentials_to_credentialtypes.py b/awx/main/migrations/0039_v320_migrate_credentials_to_credentialtypes.py deleted file mode 100644 index 9f0e0ad18c..0000000000 --- a/awx/main/migrations/0039_v320_migrate_credentials_to_credentialtypes.py +++ /dev/null @@ -1,148 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion -from django.conf import settings -import taggit.managers -import awx.main.fields -from awx.main.migrations import _credentialtypes as credentialtypes - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0038_v320_data_migrations'), - ] - - operations = [ - migrations.CreateModel( - name='CredentialType', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created', models.DateTimeField(default=None, editable=False)), - ('modified', models.DateTimeField(default=None, editable=False)), - ('description', models.TextField(default=b'', blank=True)), - ('name', models.CharField(max_length=512)), - ('kind', models.CharField(max_length=32, choices=[(b'machine', 'Machine'), (b'net', 'Network'), (b'scm', 'Source Control'), (b'cloud', 'Cloud')])), - ('managed_by_tower', models.BooleanField(default=False, editable=False)), - ('inputs', awx.main.fields.CredentialTypeInputField(default={}, blank=True)), - ('injectors', awx.main.fields.CredentialTypeInjectorField(default={}, blank=True)), - ('created_by', models.ForeignKey(related_name="{u'class': 'credentialtype', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('modified_by', models.ForeignKey(related_name="{u'class': 'credentialtype', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), - ('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')), - ], - options={ - 'ordering': ('kind', 'name'), - }, - ), - migrations.AlterModelOptions( - name='credential', - options={'ordering': ('name',)}, - ), - migrations.AddField( - model_name='credential', - name='inputs', - field=awx.main.fields.CredentialInputField(default={}, blank=True), - ), - migrations.AddField( - model_name='credential', - name='credential_type', - field=models.ForeignKey(related_name='credentials', to='main.CredentialType', null=True), - preserve_default=False, - ), - migrations.AlterUniqueTogether( - name='credential', - unique_together=set([('organization', 'name', 'credential_type')]), - ), - migrations.RunPython(credentialtypes.create_tower_managed_credential_types), - # MIGRATION TODO: For each credential, look at the columns below to - # determine the appropriate CredentialType (and assign it). Additionally, - # set `self.input` to the appropriate JSON blob - migrations.RemoveField( - model_name='credential', - name='authorize', - ), - migrations.RemoveField( - model_name='credential', - name='authorize_password', - ), - migrations.RemoveField( - model_name='credential', - name='become_method', - ), - migrations.RemoveField( - model_name='credential', - name='become_password', - ), - migrations.RemoveField( - model_name='credential', - name='become_username', - ), - migrations.RemoveField( - model_name='credential', - name='client', - ), - migrations.RemoveField( - model_name='credential', - name='cloud', - ), - migrations.RemoveField( - model_name='credential', - name='domain', - ), - migrations.RemoveField( - model_name='credential', - name='host', - ), - migrations.RemoveField( - model_name='credential', - name='kind', - ), - migrations.RemoveField( - model_name='credential', - name='password', - ), - migrations.RemoveField( - model_name='credential', - name='project', - ), - migrations.RemoveField( - model_name='credential', - name='secret', - ), - migrations.RemoveField( - model_name='credential', - name='security_token', - ), - migrations.RemoveField( - model_name='credential', - name='ssh_key_data', - ), - migrations.RemoveField( - model_name='credential', - name='ssh_key_unlock', - ), - migrations.RemoveField( - model_name='credential', - name='subscription', - ), - migrations.RemoveField( - model_name='credential', - name='tenant', - ), - migrations.RemoveField( - model_name='credential', - name='username', - ), - migrations.RemoveField( - model_name='credential', - name='vault_password', - ), - migrations.AlterUniqueTogether( - name='credentialtype', - unique_together=set([('name', 'kind')]), - ), - # MIGRATION TODO: Once credentials are migrated, alter the credential_type - # foreign key to be non-NULLable - ] diff --git a/awx/main/migrations/0040_v320_migrate_v1_credentials.py b/awx/main/migrations/0040_v320_migrate_v1_credentials.py new file mode 100644 index 0000000000..618ffc7426 --- /dev/null +++ b/awx/main/migrations/0040_v320_migrate_v1_credentials.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations +from awx.main.migrations import _credentialtypes as credentialtypes + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0039_v320_add_credentialtype_model'), + ] + + operations = [ + migrations.RunPython(credentialtypes.migrate_to_v2_credentials), + ] diff --git a/awx/main/migrations/0041_v320_drop_v1_credential_fields.py b/awx/main/migrations/0041_v320_drop_v1_credential_fields.py new file mode 100644 index 0000000000..c83f540349 --- /dev/null +++ b/awx/main/migrations/0041_v320_drop_v1_credential_fields.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0040_v320_migrate_v1_credentials'), + ] + + operations = [ + migrations.RemoveField( + model_name='credential', + name='authorize', + ), + migrations.RemoveField( + model_name='credential', + name='authorize_password', + ), + migrations.RemoveField( + model_name='credential', + name='become_method', + ), + migrations.RemoveField( + model_name='credential', + name='become_password', + ), + migrations.RemoveField( + model_name='credential', + name='become_username', + ), + migrations.RemoveField( + model_name='credential', + name='client', + ), + migrations.RemoveField( + model_name='credential', + name='cloud', + ), + migrations.RemoveField( + model_name='credential', + name='domain', + ), + migrations.RemoveField( + model_name='credential', + name='host', + ), + migrations.RemoveField( + model_name='credential', + name='kind', + ), + migrations.RemoveField( + model_name='credential', + name='password', + ), + migrations.RemoveField( + model_name='credential', + name='project', + ), + migrations.RemoveField( + model_name='credential', + name='secret', + ), + migrations.RemoveField( + model_name='credential', + name='security_token', + ), + migrations.RemoveField( + model_name='credential', + name='ssh_key_data', + ), + migrations.RemoveField( + model_name='credential', + name='ssh_key_unlock', + ), + migrations.RemoveField( + model_name='credential', + name='subscription', + ), + migrations.RemoveField( + model_name='credential', + name='tenant', + ), + migrations.RemoveField( + model_name='credential', + name='username', + ), + migrations.RemoveField( + model_name='credential', + name='vault_password', + ), + migrations.AlterUniqueTogether( + name='credentialtype', + unique_together=set([('name', 'kind')]), + ), + ] diff --git a/awx/main/migrations/_credentialtypes.py b/awx/main/migrations/_credentialtypes.py index 1e9bfbf0bf..2e794dc0e7 100644 --- a/awx/main/migrations/_credentialtypes.py +++ b/awx/main/migrations/_credentialtypes.py @@ -1,5 +1,49 @@ -from awx.main.models import CredentialType +from django.db.models.signals import post_save +from awx.main.models import Credential, CredentialType -def create_tower_managed_credential_types(apps, schema_editor): +def migrate_to_v2_credentials(apps, schema_editor): CredentialType.setup_tower_managed_defaults() + + for cred in apps.get_model('main', 'Credential').objects.all(): + data = {} + if getattr(cred, 'vault_password', None): + data['vault_password'] = cred.vault_password + credential_type = CredentialType.from_v1_kind(cred.kind, data) + defined_fields = credential_type.defined_fields + cred.credential_type = apps.get_model('main', 'CredentialType').objects.get(pk=credential_type.pk) + + # temporarily disable implicit role signals; the class we're working on + # is the "pre-migration" credential model; our signals don't like that + # it differs from the "post-migration" credential model + for field in cred.__class__.__implicit_role_fields: + post_save.disconnect(field, cred.__class__, dispatch_uid='implicit-role-post-save') + + for field in defined_fields: + if getattr(cred, field, None): + cred.inputs[field] = getattr(cred, field) + cred.save() + + # + # If the credential contains a vault password, create a new + # *additional* credential with the proper CredentialType; this needs to + # perform a deep copy of the Credential that considers: + # + if cred.vault_password: + new_fields = {} + for field in CredentialType.from_v1_kind('ssh').defined_fields: + if getattr(cred, field, None): + new_fields[field] = getattr(cred, field) + + if new_fields: + # We need to make an ssh credential, too + new_cred = Credential(credential_type=CredentialType.from_v1_kind('ssh')) + for field, value in new_fields.items(): + new_cred.inputs[field] = value + + # TODO: copy RBAC and Job Template assignments + new_cred.save() + + # re-enable implicit role signals + for field in cred.__class__.__implicit_role_fields: + post_save.connect(field._post_save, cred.__class__, True, dispatch_uid='implicit-role-post-save') diff --git a/awx/main/tests/functional/test_credential_migration.py b/awx/main/tests/functional/test_credential_migration.py new file mode 100644 index 0000000000..3764a847bf --- /dev/null +++ b/awx/main/tests/functional/test_credential_migration.py @@ -0,0 +1,313 @@ +import mock +import pytest +from contextlib import contextmanager + +from django.apps import apps + +from awx.main.models import Credential, CredentialType +from awx.main.migrations._credentialtypes import migrate_to_v2_credentials +from awx.main.utils.common import decrypt_field + +EXAMPLE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nxyz==\n-----END PRIVATE KEY-----' + +# TODO: remove this set of tests when API v1 is removed + + +@contextmanager +def migrate(credential, kind): + with mock.patch.object(Credential, 'kind', kind), \ + mock.patch.object(Credential, 'objects', mock.Mock( + get=lambda **kw: credential, + all=lambda: [credential] + )): + class Apps(apps.__class__): + def get_model(self, app, model): + if model == 'Credential': + return Credential + return apps.get_model(app, model) + yield + migrate_to_v2_credentials(Apps(), None) + + +@pytest.mark.django_db +def test_ssh_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'ssh'): + cred.__dict__.update({ + 'username': 'bob', + 'password': 'secret', + 'ssh_key_data': EXAMPLE_PRIVATE_KEY, + 'ssh_key_unlock': 'keypass', + 'become_method': 'sudo', + 'become_username': 'superuser', + 'become_password': 'superpassword', + }) + + assert cred.credential_type.name == 'SSH' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'secret' + assert cred.inputs['ssh_key_data'].startswith('$encrypted$') + assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY + assert cred.inputs['ssh_key_unlock'].startswith('$encrypted$') + assert decrypt_field(cred, 'ssh_key_unlock') == 'keypass' + assert cred.inputs['become_method'] == 'sudo' + assert cred.inputs['become_username'] == 'superuser' + assert cred.inputs['become_password'].startswith('$encrypted$') + assert decrypt_field(cred, 'become_password') == 'superpassword' + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_scm_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'scm'): + cred.__dict__.update({ + 'username': 'bob', + 'password': 'secret', + 'ssh_key_data': EXAMPLE_PRIVATE_KEY, + 'ssh_key_unlock': 'keypass', + }) + + assert cred.credential_type.name == 'Source Control' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'secret' + assert cred.inputs['ssh_key_data'].startswith('$encrypted$') + assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY + assert cred.inputs['ssh_key_unlock'].startswith('$encrypted$') + assert decrypt_field(cred, 'ssh_key_unlock') == 'keypass' + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_vault_only_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'ssh'): + cred.__dict__.update({ + 'vault_password': 'vault', + }) + + assert cred.credential_type.name == 'Vault' + assert cred.inputs['vault_password'].startswith('$encrypted$') + assert decrypt_field(cred, 'vault_password') == 'vault' + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_vault_with_ssh_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'ssh'): + cred.__dict__.update({ + 'vault_password': 'vault', + 'username': 'bob', + 'password': 'secret', + 'ssh_key_data': EXAMPLE_PRIVATE_KEY, + 'ssh_key_unlock': 'keypass', + 'become_method': 'sudo', + 'become_username': 'superuser', + 'become_password': 'superpassword', + }) + assert Credential.objects.count() == 2 + + assert Credential.objects.filter(credential_type__name='Vault').get() == cred + assert cred.inputs.keys() == ['vault_password'] + assert cred.inputs['vault_password'].startswith('$encrypted$') + assert decrypt_field(cred, 'vault_password') == 'vault' + + ssh_cred = Credential.objects.filter(credential_type__name='SSH').get() + assert sorted(ssh_cred.inputs.keys()) == sorted(CredentialType.from_v1_kind('ssh').defined_fields) + assert ssh_cred.credential_type.name == 'SSH' + assert ssh_cred.inputs['username'] == 'bob' + assert ssh_cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(ssh_cred, 'password') == 'secret' + assert ssh_cred.inputs['ssh_key_data'].startswith('$encrypted$') + assert decrypt_field(ssh_cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY + assert ssh_cred.inputs['ssh_key_unlock'].startswith('$encrypted$') + assert decrypt_field(ssh_cred, 'ssh_key_unlock') == 'keypass' + assert ssh_cred.inputs['become_method'] == 'sudo' + assert ssh_cred.inputs['become_username'] == 'superuser' + assert ssh_cred.inputs['become_password'].startswith('$encrypted$') + assert decrypt_field(ssh_cred, 'become_password') == 'superpassword' + + +@pytest.mark.django_db +def test_net_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'net'): + cred.__dict__.update({ + 'username': 'bob', + 'password': 'secret', + 'ssh_key_data': EXAMPLE_PRIVATE_KEY, + 'ssh_key_unlock': 'keypass', + 'authorize_password': 'authorize-secret', + }) + + assert cred.credential_type.name == 'Network' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'secret' + assert cred.inputs['ssh_key_data'].startswith('$encrypted$') + assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY + assert cred.inputs['ssh_key_unlock'].startswith('$encrypted$') + assert decrypt_field(cred, 'ssh_key_unlock') == 'keypass' + assert cred.inputs['authorize_password'].startswith('$encrypted$') + assert decrypt_field(cred, 'authorize_password') == 'authorize-secret' + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_aws_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'aws'): + cred.__dict__.update({ + 'username': 'bob', + 'password': 'secret', + 'security_token': 'secret-token' + }) + + assert cred.credential_type.name == 'Amazon Web Services' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'secret' + assert cred.inputs['security_token'].startswith('$encrypted$') + assert decrypt_field(cred, 'security_token') == 'secret-token' + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_openstack_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'openstack'): + cred.__dict__.update({ + 'username': 'bob', + 'password': 'secret', + 'host': 'https://keystone.example.org/', + 'project': 'TENANT_ID', + }) + + assert cred.credential_type.name == 'OpenStack' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'secret' + assert cred.inputs['host'] == 'https://keystone.example.org/' + assert cred.inputs['project'] == 'TENANT_ID' + assert Credential.objects.count() == 1 + + +@pytest.mark.skip(reason="TODO: rackspace should be a custom type (we're removing official support)") +def test_rackspace(): + pass + + +@pytest.mark.django_db +def test_vmware_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'vmware'): + cred.__dict__.update({ + 'username': 'bob', + 'password': 'secret', + 'host': 'https://example.org/', + }) + + assert cred.credential_type.name == 'VMware vCenter' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'secret' + assert cred.inputs['host'] == 'https://example.org/' + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_satellite6_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'satellite6'): + cred.__dict__.update({ + 'username': 'bob', + 'password': 'secret', + 'host': 'https://example.org/', + }) + + assert cred.credential_type.name == 'Red Hat Satellite 6' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'secret' + assert cred.inputs['host'] == 'https://example.org/' + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_cloudforms_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'cloudforms'): + cred.__dict__.update({ + 'username': 'bob', + 'password': 'secret', + 'host': 'https://example.org/', + }) + + assert cred.credential_type.name == 'Red Hat CloudForms' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'secret' + assert cred.inputs['host'] == 'https://example.org/' + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_gce_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'gce'): + cred.__dict__.update({ + 'username': 'bob', + 'project': 'PROJECT-123', + 'ssh_key_data': EXAMPLE_PRIVATE_KEY + }) + + assert cred.credential_type.name == 'Google Compute Engine' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['project'] == 'PROJECT-123' + assert cred.inputs['ssh_key_data'].startswith('$encrypted$') + assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_azure_classic_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'azure'): + cred.__dict__.update({ + 'username': 'bob', + 'ssh_key_data': EXAMPLE_PRIVATE_KEY + }) + + assert cred.credential_type.name == 'Microsoft Azure Classic (deprecated)' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['ssh_key_data'].startswith('$encrypted$') + assert decrypt_field(cred, 'ssh_key_data') == EXAMPLE_PRIVATE_KEY + assert Credential.objects.count() == 1 + + +@pytest.mark.django_db +def test_azure_rm_migration(): + cred = Credential(name='My Credential') + with migrate(cred, 'azure_rm'): + cred.__dict__.update({ + 'subscription': 'some-subscription', + 'username': 'bob', + 'password': 'some-password', + 'client': 'some-client', + 'secret': 'some-secret', + 'tenant': 'some-tenant', + }) + + assert cred.credential_type.name == 'Microsoft Azure Resource Manager' + assert cred.inputs['subscription'] == 'some-subscription' + assert cred.inputs['username'] == 'bob' + assert cred.inputs['password'].startswith('$encrypted$') + assert decrypt_field(cred, 'password') == 'some-password' + assert cred.inputs['client'] == 'some-client' + assert cred.inputs['secret'].startswith('$encrypted$') + assert decrypt_field(cred, 'secret') == 'some-secret' + assert cred.inputs['tenant'] == 'some-tenant' + assert Credential.objects.count() == 1