From 2dba02e03c1db4753a7c7f33f15e68b0da999a0c Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Fri, 4 Jan 2019 14:42:14 -0500 Subject: [PATCH 1/9] Add cloud_id column to environment model When an environment is created, we'll need to create something in the CSP and keep track of the ID of the created thing. --- ...0a9_add_cloud_id_column_to_environments.py | 28 +++++++++++++++++++ atst/models/environment.py | 2 ++ 2 files changed, 30 insertions(+) create mode 100644 alembic/versions/b3fa1493e0a9_add_cloud_id_column_to_environments.py diff --git a/alembic/versions/b3fa1493e0a9_add_cloud_id_column_to_environments.py b/alembic/versions/b3fa1493e0a9_add_cloud_id_column_to_environments.py new file mode 100644 index 00000000..d1c36439 --- /dev/null +++ b/alembic/versions/b3fa1493e0a9_add_cloud_id_column_to_environments.py @@ -0,0 +1,28 @@ +"""Add cloud_id column to environments + +Revision ID: b3fa1493e0a9 +Revises: 6172ac7b8b26 +Create Date: 2019-01-04 14:28:59.660309 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b3fa1493e0a9' +down_revision = '6172ac7b8b26' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('environments', sa.Column('cloud_id', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('environments', 'cloud_id') + # ### end Alembic commands ### diff --git a/atst/models/environment.py b/atst/models/environment.py index c5eaf98b..01348d39 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -15,6 +15,8 @@ class Environment(Base, mixins.TimestampsMixin, mixins.AuditableMixin): project_id = Column(ForeignKey("projects.id"), nullable=False) project = relationship("Project") + cloud_id = Column(String) + @property def users(self): return [r.user for r in self.roles] From 3dad43b1ee1ba8188e7fb41b9bb18c95330ab724 Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Fri, 4 Jan 2019 14:44:06 -0500 Subject: [PATCH 2/9] Add initial mock cloud provider class --- atst/domain/csp/__init__.py | 2 ++ atst/domain/csp/cloud.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 atst/domain/csp/cloud.py diff --git a/atst/domain/csp/__init__.py b/atst/domain/csp/__init__.py index d2db2b2a..a40d200c 100644 --- a/atst/domain/csp/__init__.py +++ b/atst/domain/csp/__init__.py @@ -1,9 +1,11 @@ +from .cloud import MockCloudProvider from .files import RackspaceFileProvider from .reports import MockReportingProvider class MockCSP: def __init__(self, app): + self.cloud = MockCloudProvider() self.files = RackspaceFileProvider(app) self.reports = MockReportingProvider() diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py new file mode 100644 index 00000000..ca10aba6 --- /dev/null +++ b/atst/domain/csp/cloud.py @@ -0,0 +1,16 @@ +from uuid import uuid4 + + +class CloudProviderInterface: + def create_application(self, name): # pragma: no cover + """Create an application in the cloud with the provided name. Returns + the ID of the created object. + """ + raise NotImplementedError() + + +class MockCloudProvider(CloudProviderInterface): + def create_application(self, name): + """Returns an id that represents what would be an application in the + cloud.""" + return uuid4().hex From 334babe5ff8601981b7365561b187908e9d6c819 Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Fri, 4 Jan 2019 14:44:33 -0500 Subject: [PATCH 3/9] Hook into cloud provider when creating an env/app --- atst/domain/environments.py | 6 +++--- tests/domain/test_environments.py | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/atst/domain/environments.py b/atst/domain/environments.py index b8da2282..82e96ec0 100644 --- a/atst/domain/environments.py +++ b/atst/domain/environments.py @@ -1,3 +1,4 @@ +from flask import current_app as app from sqlalchemy.orm.exc import NoResultFound from atst.database import db @@ -15,15 +16,14 @@ class Environments(object): @classmethod def create(cls, project, name): environment = Environment(project=project, name=name) - db.session.add(environment) - db.session.commit() + environment.cloud_id = app.csp.cloud.create_application(environment.name) return environment @classmethod def create_many(cls, project, names): environments = [] for name in names: - environment = Environment(project=project, name=name) + environment = Environments.create(project, name) environments.append(environment) db.session.add_all(environments) diff --git a/tests/domain/test_environments.py b/tests/domain/test_environments.py index 9f5e9aab..edd2f1c1 100644 --- a/tests/domain/test_environments.py +++ b/tests/domain/test_environments.py @@ -2,7 +2,14 @@ from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles from atst.domain.workspace_roles import WorkspaceRoles -from tests.factories import UserFactory, WorkspaceFactory +from tests.factories import ProjectFactory, UserFactory, WorkspaceFactory + + +def test_create_environments(): + project = ProjectFactory.create() + environments = Environments.create_many(project, ["Staging", "Production"]) + for env in environments: + assert env.cloud_id is not None def test_update_environment_roles(): From 0798ce4019b9247f93044f0841eaf3dae623ef65 Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Fri, 4 Jan 2019 14:57:15 -0500 Subject: [PATCH 4/9] Add hook to CSP when creating environment role --- atst/domain/csp/cloud.py | 14 ++++++++++++++ atst/domain/environment_roles.py | 8 ++++++++ atst/domain/environments.py | 4 ++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index ca10aba6..0fd7d3c0 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -8,9 +8,23 @@ class CloudProviderInterface: """ raise NotImplementedError() + def create_role(self, environment_role): # pragma: no cover + """Takes an `atst.model.EnvironmentRole` object and allows the + specified user access to the specified cloud entity. + + This _does not_ return a token, but is intended to perform any necessary + setup to allow a token to be generated in the future (for example, + add the user to the cloud entity in some fashion). + """ + raise NotImplementedError() + class MockCloudProvider(CloudProviderInterface): def create_application(self, name): """Returns an id that represents what would be an application in the cloud.""" return uuid4().hex + + def create_role(self, environment_role): + # Currently, there is nothing to mock out, so just do nothing. + pass diff --git a/atst/domain/environment_roles.py b/atst/domain/environment_roles.py index 18d056f1..8c38ec21 100644 --- a/atst/domain/environment_roles.py +++ b/atst/domain/environment_roles.py @@ -1,8 +1,16 @@ +from flask import current_app as app + from atst.models.environment_role import EnvironmentRole from atst.database import db class EnvironmentRoles(object): + @classmethod + def create(cls, user, environment, role): + env_role = EnvironmentRole(user=user, environment=environment, role=role) + app.csp.cloud.create_role(env_role) + return env_role + @classmethod def get(cls, user_id, environment_id): existing_env_role = ( diff --git a/atst/domain/environments.py b/atst/domain/environments.py index 82e96ec0..d1658ea0 100644 --- a/atst/domain/environments.py +++ b/atst/domain/environments.py @@ -31,7 +31,7 @@ class Environments(object): @classmethod def add_member(cls, environment, user, role): - environment_user = EnvironmentRole( + environment_user = EnvironmentRoles.create( user=user, environment=environment, role=role ) db.session.add(environment_user) @@ -86,7 +86,7 @@ class Environments(object): updated = True db.session.add(env_role) elif not env_role: - env_role = EnvironmentRole( + env_role = EnvironmentRoles.create( user=workspace_role.user, environment=environment, role=new_role ) updated = True From c89e5b824c794c209a049647ed4a25e5a7a7b12a Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Mon, 7 Jan 2019 15:45:15 -0500 Subject: [PATCH 5/9] Add hook into CSP when deleting an environment role --- atst/domain/csp/cloud.py | 11 +++++++++++ atst/domain/environment_roles.py | 1 + 2 files changed, 12 insertions(+) diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index 0fd7d3c0..973d6283 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -18,6 +18,13 @@ class CloudProviderInterface: """ raise NotImplementedError() + def delete_role(self, environment_role): # pragma: no cover + """Takes an `atst.model.EnvironmentRole` object and performs any action + necessary in the CSP to remove the specified user from the specified + environment. This method does not return anything. + """ + raise NotImplementedError() + class MockCloudProvider(CloudProviderInterface): def create_application(self, name): @@ -28,3 +35,7 @@ class MockCloudProvider(CloudProviderInterface): def create_role(self, environment_role): # Currently, there is nothing to mock out, so just do nothing. pass + + def delete_role(self, environment_role): + # Currently nothing to do. + pass diff --git a/atst/domain/environment_roles.py b/atst/domain/environment_roles.py index 8c38ec21..012ceabd 100644 --- a/atst/domain/environment_roles.py +++ b/atst/domain/environment_roles.py @@ -27,6 +27,7 @@ class EnvironmentRoles(object): def delete(cls, user_id, environment_id): existing_env_role = EnvironmentRoles.get(user_id, environment_id) if existing_env_role: + app.csp.cloud.delete_role(existing_env_role) db.session.delete(existing_env_role) db.session.commit() return True From 91419af71ae1a5e052fafc7df24d0ca45d9e88de Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Mon, 7 Jan 2019 17:29:53 -0500 Subject: [PATCH 6/9] Generate mock access token when access env --- atst/domain/csp/cloud.py | 15 ++++++++ atst/routes/workspaces/projects.py | 23 +++++++++++- templates/workspaces/projects/index.html | 2 +- tests/routes/workspaces/test_projects.py | 48 +++++++++++++++++++++++- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index 973d6283..d624d9b6 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -25,6 +25,13 @@ class CloudProviderInterface: """ raise NotImplementedError() + def get_access_token(self, environment_role): # pragma: no cover + """Takes an `atst.model.EnvironmentRole` object and returns a federated + access token that gives the specified user access to the specified + environment with the proper permissions. + """ + raise NotImplementedError() + class MockCloudProvider(CloudProviderInterface): def create_application(self, name): @@ -39,3 +46,11 @@ class MockCloudProvider(CloudProviderInterface): def delete_role(self, environment_role): # Currently nothing to do. pass + + def get_access_token(self, environment_role): + # for now, just create a mock token using the user and environement + # cloud IDs and the name of the role in the environment + user_id = str(environment_role.user.id) + env_id = environment_role.environment.cloud_id or "" + role_details = environment_role.role + return "::".join([user_id, env_id, role_details]) diff --git a/atst/routes/workspaces/projects.py b/atst/routes/workspaces/projects.py index cc6f10f8..67cc2931 100644 --- a/atst/routes/workspaces/projects.py +++ b/atst/routes/workspaces/projects.py @@ -1,6 +1,15 @@ -from flask import render_template, request as http_request, g, redirect, url_for +from flask import ( + current_app as app, + g, + redirect, + render_template, + request as http_request, + url_for, +) from . import workspaces_bp +from atst.domain.environment_roles import EnvironmentRoles +from atst.domain.exceptions import UnauthorizedError from atst.domain.projects import Projects from atst.domain.workspaces import Workspaces from atst.forms.project import NewProjectForm, ProjectForm @@ -76,3 +85,15 @@ def update_project(workspace_id, project_id): project=project, form=form, ) + + +@workspaces_bp.route("/workspaces//environments//access") +def access_environment(workspace_id, environment_id): + env_role = EnvironmentRoles.get(g.current_user.id, environment_id) + if not env_role: + raise UnauthorizedError( + g.current_user, "access environment {}".format(environment_id) + ) + else: + token = app.csp.cloud.get_access_token(env_role) + return redirect(url_for("atst.csp_environment_access", token=token)) diff --git a/templates/workspaces/projects/index.html b/templates/workspaces/projects/index.html index 42333e50..3244f3a0 100644 --- a/templates/workspaces/projects/index.html +++ b/templates/workspaces/projects/index.html @@ -34,7 +34,7 @@
    {% for environment in project.environments %}
  • - + {{ Icon('link') }} {{ environment.name }} diff --git a/tests/routes/workspaces/test_projects.py b/tests/routes/workspaces/test_projects.py index 44ee822d..af697c3c 100644 --- a/tests/routes/workspaces/test_projects.py +++ b/tests/routes/workspaces/test_projects.py @@ -1,6 +1,13 @@ from flask import url_for -from tests.factories import UserFactory, WorkspaceFactory +from tests.factories import ( + UserFactory, + WorkspaceFactory, + WorkspaceRoleFactory, + EnvironmentRoleFactory, + EnvironmentFactory, + ProjectFactory, +) from atst.domain.projects import Projects from atst.domain.workspaces import Workspaces from atst.models.workspace_role import Status as WorkspaceRoleStatus @@ -125,3 +132,42 @@ def test_user_without_permission_cannot_update_project(client, user_session): assert response.status_code == 404 assert project.name == "Great Project" assert project.description == "Cool stuff happening here!" + + +def create_environment(user): + workspace = WorkspaceFactory.create() + workspace_role = WorkspaceRoleFactory.create(workspace=workspace, user=user) + project = ProjectFactory.create(workspace=workspace) + return EnvironmentFactory.create(project=project, name="new environment!") + + +def test_environment_access_with_env_role(client, user_session): + user = UserFactory.create() + environment = create_environment(user) + env_role = EnvironmentRoleFactory.create( + user=user, environment=environment, role="developer" + ) + user_session(user) + response = client.get( + url_for( + "workspaces.access_environment", + workspace_id=environment.workspace.id, + environment_id=environment.id, + ) + ) + assert response.status_code == 302 + assert "csp-environment-access" in response.location + + +def test_environment_access_with_no_role(client, user_session): + user = UserFactory.create() + environment = create_environment(user) + user_session(user) + response = client.get( + url_for( + "workspaces.access_environment", + workspace_id=environment.workspace.id, + environment_id=environment.id, + ) + ) + assert response.status_code == 404 From 5e737bad15b8e67d504b50917b922aa6c82e512b Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Tue, 8 Jan 2019 10:43:05 -0500 Subject: [PATCH 7/9] Add cloud_id to user model --- ...1bd9482ce23_add_cloud_id_column_to_user.py | 28 +++++++++++++++++++ atst/models/user.py | 2 ++ 2 files changed, 30 insertions(+) create mode 100644 alembic/versions/91bd9482ce23_add_cloud_id_column_to_user.py diff --git a/alembic/versions/91bd9482ce23_add_cloud_id_column_to_user.py b/alembic/versions/91bd9482ce23_add_cloud_id_column_to_user.py new file mode 100644 index 00000000..e404b587 --- /dev/null +++ b/alembic/versions/91bd9482ce23_add_cloud_id_column_to_user.py @@ -0,0 +1,28 @@ +"""Add cloud_id column to user + +Revision ID: 91bd9482ce23 +Revises: b3fa1493e0a9 +Create Date: 2019-01-08 10:18:23.764179 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '91bd9482ce23' +down_revision = 'b3fa1493e0a9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('cloud_id', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'cloud_id') + # ### end Alembic commands ### diff --git a/atst/models/user.py b/atst/models/user.py index aed21662..f6d8de62 100644 --- a/atst/models/user.py +++ b/atst/models/user.py @@ -29,6 +29,8 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin): provisional = Column(Boolean) + cloud_id = Column(String) + REQUIRED_FIELDS = [ "email", "dod_id", From 28e0d423fd2b3d0a0b3a5fb70dcaf2ec7adb622c Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Tue, 8 Jan 2019 12:13:16 -0500 Subject: [PATCH 8/9] Create user account in CSP when adding an environment role --- atst/domain/csp/cloud.py | 14 ++++++++++++-- atst/domain/environment_roles.py | 2 ++ tests/domain/test_environments.py | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index d624d9b6..3b071b8d 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -8,6 +8,12 @@ class CloudProviderInterface: """ raise NotImplementedError() + def create_user(self, user): # pragma: no cover + """Create an account in the CSP for specified user. Returns the ID of + the created user. + """ + raise NotImplementedError() + def create_role(self, environment_role): # pragma: no cover """Takes an `atst.model.EnvironmentRole` object and allows the specified user access to the specified cloud entity. @@ -39,6 +45,10 @@ class MockCloudProvider(CloudProviderInterface): cloud.""" return uuid4().hex + def create_user(self, user): + """Returns an id that represents what would be an user in the cloud.""" + return uuid4().hex + def create_role(self, environment_role): # Currently, there is nothing to mock out, so just do nothing. pass @@ -48,9 +58,9 @@ class MockCloudProvider(CloudProviderInterface): pass def get_access_token(self, environment_role): - # for now, just create a mock token using the user and environement + # for now, just create a mock token using the user and environment # cloud IDs and the name of the role in the environment - user_id = str(environment_role.user.id) + user_id = environment_role.user.cloud_id or "" env_id = environment_role.environment.cloud_id or "" role_details = environment_role.role return "::".join([user_id, env_id, role_details]) diff --git a/atst/domain/environment_roles.py b/atst/domain/environment_roles.py index 012ceabd..d5200ac4 100644 --- a/atst/domain/environment_roles.py +++ b/atst/domain/environment_roles.py @@ -8,6 +8,8 @@ class EnvironmentRoles(object): @classmethod def create(cls, user, environment, role): env_role = EnvironmentRole(user=user, environment=environment, role=role) + if not user.cloud_id: + user.cloud_id = app.csp.cloud.create_user(user) app.csp.cloud.create_role(env_role) return env_role diff --git a/tests/domain/test_environments.py b/tests/domain/test_environments.py index edd2f1c1..0095fa2c 100644 --- a/tests/domain/test_environments.py +++ b/tests/domain/test_environments.py @@ -12,6 +12,28 @@ def test_create_environments(): assert env.cloud_id is not None +def test_create_environment_role_creates_cloud_id(session): + owner = UserFactory.create() + developer = UserFactory.from_atat_role("developer") + + workspace = WorkspaceFactory.create( + owner=owner, + members=[{"user": developer, "role_name": "developer"}], + projects=[{"name": "project1", "environments": [{"name": "project1 prod"}]}], + ) + + env = workspace.projects[0].environments[0] + new_role = [{"id": env.id, "role": "developer"}] + + workspace_role = workspace.members[0] + assert not workspace_role.user.cloud_id + assert Environments.update_environment_roles( + owner, workspace, workspace_role, new_role + ) + + assert workspace_role.user.cloud_id is not None + + def test_update_environment_roles(): owner = UserFactory.create() developer = UserFactory.from_atat_role("developer") From 87a7dede6c91fa65442a56341af8c3012942139c Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Tue, 8 Jan 2019 16:34:27 -0500 Subject: [PATCH 9/9] Resurrect erroneously removed session commit --- atst/domain/environments.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/atst/domain/environments.py b/atst/domain/environments.py index d1658ea0..680248cd 100644 --- a/atst/domain/environments.py +++ b/atst/domain/environments.py @@ -17,6 +17,8 @@ class Environments(object): def create(cls, project, name): environment = Environment(project=project, name=name) environment.cloud_id = app.csp.cloud.create_application(environment.name) + db.session.add(environment) + db.session.commit() return environment @classmethod