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/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/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..3b071b8d --- /dev/null +++ b/atst/domain/csp/cloud.py @@ -0,0 +1,66 @@ +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() + + 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. + + 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() + + 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() + + 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): + """Returns an id that represents what would be an application in the + 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 + + 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 environment + # cloud IDs and the name of the role in the environment + 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 18d056f1..d5200ac4 100644 --- a/atst/domain/environment_roles.py +++ b/atst/domain/environment_roles.py @@ -1,8 +1,18 @@ +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) + if not user.cloud_id: + user.cloud_id = app.csp.cloud.create_user(user) + app.csp.cloud.create_role(env_role) + return env_role + @classmethod def get(cls, user_id, environment_id): existing_env_role = ( @@ -19,6 +29,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 diff --git a/atst/domain/environments.py b/atst/domain/environments.py index b8da2282..680248cd 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,6 +16,7 @@ class Environments(object): @classmethod 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 @@ -23,7 +25,7 @@ class Environments(object): 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) @@ -31,7 +33,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 +88,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 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] 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", 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 @@