diff --git a/Pipfile.lock b/Pipfile.lock index cf9730d8..11faecf5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -322,11 +322,11 @@ }, "requests": { "hashes": [ - "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", - "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" + "sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c", + "sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279" ], "index": "pypi", - "version": "==2.19.1" + "version": "==2.20.0" }, "rq": { "hashes": [ diff --git a/alembic/versions/5284ac1ac77c_add_provisional_column_to_users.py b/alembic/versions/5284ac1ac77c_add_provisional_column_to_users.py new file mode 100644 index 00000000..7078b713 --- /dev/null +++ b/alembic/versions/5284ac1ac77c_add_provisional_column_to_users.py @@ -0,0 +1,28 @@ +"""add provisional column to users + +Revision ID: 5284ac1ac77c +Revises: e0fc3cd315c1 +Create Date: 2018-10-25 11:04:49.879393 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5284ac1ac77c' +down_revision = 'e0fc3cd315c1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('provisional', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'provisional') + # ### end Alembic commands ### diff --git a/alembic/versions/994a80ee92c9_add_invitations.py b/alembic/versions/994a80ee92c9_add_invitations.py new file mode 100644 index 00000000..c480ec9b --- /dev/null +++ b/alembic/versions/994a80ee92c9_add_invitations.py @@ -0,0 +1,50 @@ +"""add invitations + +Revision ID: 994a80ee92c9 +Revises: 9c24c609878a +Create Date: 2018-10-30 16:49:53.688621 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '994a80ee92c9' +down_revision = '9c24c609878a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('invitations', + sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('workspace_role_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('inviter_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('status', sa.Enum('ACCEPTED', 'REVOKED', 'PENDING', 'REJECTED', name='status', native_enum=False), nullable=True), + sa.Column('expiration_time', sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column('token', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['inviter_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['workspace_role_id'], ['workspace_roles.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_invitations_inviter_id'), 'invitations', ['inviter_id'], unique=False) + op.create_index(op.f('ix_invitations_token'), 'invitations', ['token'], unique=False) + op.create_index(op.f('ix_invitations_user_id'), 'invitations', ['user_id'], unique=False) + op.create_index(op.f('ix_invitations_workspace_role_id'), 'invitations', ['workspace_role_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invitations_workspace_role_id'), table_name='invitations') + op.drop_index(op.f('ix_invitations_user_id'), table_name='invitations') + op.drop_index(op.f('ix_invitations_token'), table_name='invitations') + op.drop_index(op.f('ix_invitations_inviter_id'), table_name='invitations') + op.drop_table('invitations') + # ### end Alembic commands ### diff --git a/alembic/versions/a9d8c6b6221c_status_for_existing_workspace_roles.py b/alembic/versions/a9d8c6b6221c_status_for_existing_workspace_roles.py new file mode 100644 index 00000000..25ba77cc --- /dev/null +++ b/alembic/versions/a9d8c6b6221c_status_for_existing_workspace_roles.py @@ -0,0 +1,30 @@ +"""status for existing workspace roles + +Revision ID: a9d8c6b6221c +Revises: 5284ac1ac77c +Create Date: 2018-10-31 11:08:05.791739 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a9d8c6b6221c' +down_revision = '5284ac1ac77c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + conn.execute("UPDATE workspace_roles set status = 'ACTIVE'") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + conn.execute("UPDATE workspace_roles set status = null") + # ### end Alembic commands ### diff --git a/alembic/versions/e0fc3cd315c1_add_status_to_workspace_roles.py b/alembic/versions/e0fc3cd315c1_add_status_to_workspace_roles.py new file mode 100644 index 00000000..a852e5ef --- /dev/null +++ b/alembic/versions/e0fc3cd315c1_add_status_to_workspace_roles.py @@ -0,0 +1,28 @@ +"""add status to workspace_roles + +Revision ID: e0fc3cd315c1 +Revises: 994a80ee92c9 +Create Date: 2018-10-30 14:36:51.047876 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e0fc3cd315c1' +down_revision = '994a80ee92c9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('workspace_roles', sa.Column('status', sa.Enum('ACTIVE', 'DISABLED', 'PENDING', name='status', native_enum=False), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('workspace_roles', 'status') + # ### end Alembic commands ### diff --git a/atst/domain/authz.py b/atst/domain/authz.py index ce422736..fc4b4c32 100644 --- a/atst/domain/authz.py +++ b/atst/domain/authz.py @@ -6,8 +6,7 @@ from atst.domain.exceptions import UnauthorizedError class Authorization(object): @classmethod def has_workspace_permission(cls, user, workspace, permission): - workspace_user = WorkspaceUsers.get(workspace.id, user.id) - return permission in workspace_user.permissions() + return permission in WorkspaceUsers.workspace_user_permissions(workspace, user) @classmethod def has_atat_permission(cls, user, permission): diff --git a/atst/domain/invitations.py b/atst/domain/invitations.py new file mode 100644 index 00000000..ee518c4e --- /dev/null +++ b/atst/domain/invitations.py @@ -0,0 +1,70 @@ +import datetime +from sqlalchemy.orm.exc import NoResultFound + +from atst.database import db +from atst.models.invitation import Invitation, Status as InvitationStatus +from atst.domain.workspace_users import WorkspaceUsers + +from .exceptions import NotFoundError + + +class InvitationError(Exception): + def __init__(self, invite): + self.invite = invite + + @property + def message(self): + return "{} has a status of {}".format(self.invite.id, self.invite.status.value) + + +class Invitations(object): + # number of minutes a given invitation is considered valid + EXPIRATION_LIMIT_MINUTES = 360 + + @classmethod + def _get(cls, token): + try: + invite = db.session.query(Invitation).filter_by(token=token).one() + except NoResultFound: + raise NotFoundError("invite") + + return invite + + @classmethod + def create(cls, workspace_role, inviter, user): + invite = Invitation( + workspace_role=workspace_role, + inviter=inviter, + user=user, + status=InvitationStatus.PENDING, + expiration_time=Invitations.current_expiration_time(), + ) + db.session.add(invite) + db.session.commit() + + return invite + + @classmethod + def accept(cls, token): + invite = Invitations._get(token) + + if invite.is_expired: + invite.status = InvitationStatus.REJECTED + elif invite.is_pending: + invite.status = InvitationStatus.ACCEPTED + + db.session.add(invite) + db.session.commit() + + if invite.is_revoked or invite.is_rejected: + raise InvitationError(invite) + + WorkspaceUsers.enable(invite.workspace_role) + + return invite + + @classmethod + def current_expiration_time(cls): + return datetime.datetime.now() + datetime.timedelta( + minutes=Invitations.EXPIRATION_LIMIT_MINUTES + ) diff --git a/atst/domain/users.py b/atst/domain/users.py index 7494f5f4..97947080 100644 --- a/atst/domain/users.py +++ b/atst/domain/users.py @@ -89,3 +89,12 @@ class Users(object): db.session.commit() return user + + @classmethod + def finalize(cls, user): + user.provisional = False + + db.session.add(user) + db.session.commit() + + return user diff --git a/atst/domain/workspace_users.py b/atst/domain/workspace_users.py index d37ec736..c5f4075d 100644 --- a/atst/domain/workspace_users.py +++ b/atst/domain/workspace_users.py @@ -1,7 +1,7 @@ from sqlalchemy.orm.exc import NoResultFound from atst.database import db -from atst.models.workspace_role import WorkspaceRole +from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus from atst.models.workspace_user import WorkspaceUser from atst.models.user import User @@ -30,6 +30,30 @@ class WorkspaceUsers(object): return WorkspaceUser(user, workspace_role) + @classmethod + def _get_active_workspace_role(cls, workspace_id, user_id): + try: + return ( + db.session.query(WorkspaceRole) + .join(User) + .filter(User.id == user_id, WorkspaceRole.workspace_id == workspace_id) + .filter(WorkspaceRole.status == WorkspaceRoleStatus.ACTIVE) + .one() + ) + except NoResultFound: + return None + + @classmethod + def workspace_user_permissions(cls, workspace, user): + workspace_role = WorkspaceUsers._get_active_workspace_role( + workspace.id, user.id + ) + atat_permissions = set(user.atat_role.permissions) + workspace_permissions = ( + [] if workspace_role is None else workspace_role.role.permissions + ) + return set(workspace_permissions).union(atat_permissions) + @classmethod def _get_workspace_role(cls, user, workspace_id): try: @@ -123,3 +147,10 @@ class WorkspaceUsers(object): db.session.commit() return workspace_users + + @classmethod + def enable(cls, workspace_role): + workspace_role.status = WorkspaceRoleStatus.ACTIVE + + db.session.add(workspace_role) + db.session.commit() diff --git a/atst/domain/workspaces/query.py b/atst/domain/workspaces/query.py index ec32c73c..6ac00d8a 100644 --- a/atst/domain/workspaces/query.py +++ b/atst/domain/workspaces/query.py @@ -4,7 +4,7 @@ from atst.database import db from atst.domain.common import Query from atst.domain.exceptions import NotFoundError from atst.models.workspace import Workspace -from atst.models.workspace_role import WorkspaceRole +from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus class WorkspacesQuery(Query): @@ -25,9 +25,10 @@ class WorkspacesQuery(Query): db.session.query(Workspace) .join(WorkspaceRole) .filter(WorkspaceRole.user == user) + .filter(WorkspaceRole.status == WorkspaceRoleStatus.ACTIVE) .all() ) @classmethod - def create_workspace_role(cls, user, role, workspace): - return WorkspaceRole(user=user, role=role, workspace=workspace) + def create_workspace_role(cls, user, role, workspace, **kwargs): + return WorkspaceRole(user=user, role=role, workspace=workspace, **kwargs) diff --git a/atst/domain/workspaces/workspaces.py b/atst/domain/workspaces/workspaces.py index f8e2c560..aecfbc00 100644 --- a/atst/domain/workspaces/workspaces.py +++ b/atst/domain/workspaces/workspaces.py @@ -3,6 +3,7 @@ from atst.domain.authz import Authorization from atst.models.permissions import Permissions from atst.domain.users import Users from atst.domain.workspace_users import WorkspaceUsers +from atst.models.workspace_role import Status as WorkspaceRoleStatus from .query import WorkspacesQuery from .scopes import ScopedWorkspace @@ -13,7 +14,9 @@ class Workspaces(object): def create(cls, request, name=None): name = name or request.displayname workspace = WorkspacesQuery.create(request=request, name=name) - Workspaces._create_workspace_role(request.creator, workspace, "owner") + Workspaces._create_workspace_role( + request.creator, workspace, "owner", status=WorkspaceRoleStatus.ACTIVE + ) WorkspacesQuery.add_and_commit(workspace) return workspace @@ -86,6 +89,7 @@ class Workspaces(object): last_name=data["last_name"], email=data["email"], atat_role_name="default", + provisional=True, ) return Workspaces.add_member(workspace, new_user, data["workspace_role"]) @@ -106,9 +110,13 @@ class Workspaces(object): return WorkspaceUsers.update_role(member, workspace.id, role_name) @classmethod - def _create_workspace_role(cls, user, workspace, role_name): + def _create_workspace_role( + cls, user, workspace, role_name, status=WorkspaceRoleStatus.PENDING + ): role = Roles.get(role_name) - workspace_role = WorkspacesQuery.create_workspace_role(user, role, workspace) + workspace_role = WorkspacesQuery.create_workspace_role( + user, role, workspace, status=status + ) WorkspacesQuery.add_and_commit(workspace_role) return workspace_role diff --git a/atst/models/__init__.py b/atst/models/__init__.py index 91cee016..27afc327 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -18,3 +18,4 @@ from .request_revision import RequestRevision from .request_review import RequestReview from .request_internal_comment import RequestInternalComment from .audit_event import AuditEvent +from .invitation import Invitation diff --git a/atst/models/invitation.py b/atst/models/invitation.py new file mode 100644 index 00000000..60f03b3c --- /dev/null +++ b/atst/models/invitation.py @@ -0,0 +1,71 @@ +import datetime +from enum import Enum +import secrets + +from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from atst.models import Base, types +from atst.models.mixins.timestamps import TimestampsMixin +from atst.models.mixins.auditable import AuditableMixin + + +class Status(Enum): + ACCEPTED = "accepted" + REVOKED = "revoked" + PENDING = "pending" + REJECTED = "rejected" + + +class Invitation(Base, TimestampsMixin, AuditableMixin): + __tablename__ = "invitations" + + id = types.Id() + + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + user = relationship("User", backref="invitations", foreign_keys=[user_id]) + + workspace_role_id = Column( + UUID(as_uuid=True), ForeignKey("workspace_roles.id"), index=True + ) + workspace_role = relationship("WorkspaceRole", backref="invitations") + + inviter_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + inviter = relationship("User", backref="sent_invites", foreign_keys=[inviter_id]) + + status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING)) + + expiration_time = Column(TIMESTAMP(timezone=True)) + + token = Column(String(), index=True, default=lambda: secrets.token_urlsafe()) + + def __repr__(self): + return "".format( + self.user_id, self.workspace_role_id, self.id + ) + + @property + def is_accepted(self): + return self.status == Status.ACCEPTED + + @property + def is_revoked(self): + return self.status == Status.REVOKED + + @property + def is_pending(self): + return self.status == Status.PENDING + + @property + def is_rejected(self): + return self.status == Status.REJECTED + + @property + def is_expired(self): + return datetime.datetime.now(self.expiration_time.tzinfo) > self.expiration_time + + @property + def workspace(self): + if self.workspace_role: + return self.workspace_role.workspace diff --git a/atst/models/user.py b/atst/models/user.py index 924e3425..d7bdcc28 100644 --- a/atst/models/user.py +++ b/atst/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, ForeignKey, Column, Date +from sqlalchemy import String, ForeignKey, Column, Date, Boolean from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import UUID @@ -25,6 +25,8 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin): citizenship = Column(String) designation = Column(String) date_latest_training = Column(Date) + + provisional = Column(Boolean) REQUIRED_FIELDS = [ "email", diff --git a/atst/models/workspace.py b/atst/models/workspace.py index 11f32e27..659dca6a 100644 --- a/atst/models/workspace.py +++ b/atst/models/workspace.py @@ -49,6 +49,6 @@ class Workspace(Base, mixins.TimestampsMixin, mixins.AuditableMixin): return self.id def __repr__(self): - return "".format( - self.name, self.request_id, self.task_order.number, self.user_count, self.id + return "".format( + self.name, self.request_id, self.user_count, self.id ) diff --git a/atst/models/workspace_role.py b/atst/models/workspace_role.py index 87c8f46f..97074602 100644 --- a/atst/models/workspace_role.py +++ b/atst/models/workspace_role.py @@ -1,4 +1,5 @@ -from sqlalchemy import Index, ForeignKey, Column +from enum import Enum +from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -6,6 +7,12 @@ from atst.models import Base, mixins from .types import Id +class Status(Enum): + ACTIVE = "active" + DISABLED = "disabled" + PENDING = "pending" + + class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): __tablename__ = "workspace_roles" @@ -22,6 +29,8 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False ) + status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING)) + def __repr__(self): return "".format( self.role.name, self.workspace.name, self.user_id, self.id diff --git a/atst/models/workspace_user.py b/atst/models/workspace_user.py index e61182e4..216b07aa 100644 --- a/atst/models/workspace_user.py +++ b/atst/models/workspace_user.py @@ -9,13 +9,6 @@ class WorkspaceUser(object): self.user = user self.workspace_role = workspace_role - def permissions(self): - atat_permissions = set(self.user.atat_role.permissions) - workspace_permissions = ( - [] if self.workspace_role is None else self.workspace_role.role.permissions - ) - return set(workspace_permissions).union(atat_permissions) - @property def workspace(self): return self.workspace_role.workspace @@ -74,8 +67,8 @@ class WorkspaceUser(object): def __repr__(self): return "".format( - self.user_name, - self.role.name, + self.user.full_name, + self.role, self.workspace.name, self.num_environment_roles, ) diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index 5051ad5a..0149ace5 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -101,6 +101,10 @@ def login_redirect(): auth_context = _make_authentication_context() auth_context.authenticate() user = auth_context.get_user() + + if user.provisional: + Users.finalize(user) + session["user_id"] = user.id return redirect(redirect_after_login_url()) diff --git a/atst/routes/errors.py b/atst/routes/errors.py index b3e7effd..4871f89d 100644 --- a/atst/routes/errors.py +++ b/atst/routes/errors.py @@ -3,6 +3,7 @@ from flask_wtf.csrf import CSRFError import werkzeug.exceptions as werkzeug_exceptions import atst.domain.exceptions as exceptions +from atst.domain.invitations import InvitationError def make_error_pages(app): @@ -41,4 +42,15 @@ def make_error_pages(app): 500, ) + @app.errorhandler(InvitationError) + # pylint: disable=unused-variable + def invalid_invitation(e): + log_error(e) + return ( + render_template( + "error.html", message="The invitation link you clicked is invalid." + ), + 404, + ) + return app diff --git a/atst/routes/workspaces.py b/atst/routes/workspaces.py index 5e295b70..b0c751a6 100644 --- a/atst/routes/workspaces.py +++ b/atst/routes/workspaces.py @@ -24,6 +24,8 @@ from atst.forms.workspace import WorkspaceForm from atst.forms.data import ENVIRONMENT_ROLES, ENV_ROLE_MODAL_DESCRIPTION from atst.domain.authz import Authorization from atst.models.permissions import Permissions +from atst.domain.invitations import Invitations +from atst.queue import queue bp = Blueprint("workspaces", __name__) @@ -218,6 +220,15 @@ def new_member(workspace_id): ) +def send_invite_email(owner_name, token, new_member_email): + body = render_template("emails/invitation.txt", owner=owner_name, token=token) + queue.send_mail( + [new_member_email], + "{} has invited you to a JEDI Cloud Workspace".format(owner_name), + body, + ) + + @bp.route("/workspaces//members/new", methods=["POST"]) def create_member(workspace_id): workspace = Workspaces.get(g.current_user, workspace_id) @@ -226,6 +237,13 @@ def create_member(workspace_id): if form.validate(): try: new_member = Workspaces.create_member(g.current_user, workspace, form.data) + invite = Invitations.create( + new_member.workspace_role, g.current_user, new_member.user + ) + send_invite_email( + g.current_user.full_name, invite.token, new_member.user.email + ) + return redirect( url_for( "workspaces.workspace_members", @@ -318,3 +336,14 @@ def update_member(workspace_id, member_id): workspace=workspace, member=member, ) + + +@bp.route("/workspaces/invitation/", methods=["GET"]) +def accept_invitation(token): + # TODO: check that the current_user DOD ID matches the user associated with + # the invitation + invite = Invitations.accept(token) + + return redirect( + url_for("workspaces.show_workspace", workspace_id=invite.workspace.id) + ) diff --git a/templates/emails/invitation.txt b/templates/emails/invitation.txt new file mode 100644 index 00000000..02fdd49d --- /dev/null +++ b/templates/emails/invitation.txt @@ -0,0 +1,12 @@ +Join this JEDI Cloud Workspace +{{ owner }} has invited you to join a JEDI Cloud Workspace. Login now to view or use your JEDI Cloud resources. + +{{ url_for("workspaces.accept_invitation", token=token, _external=True) }} + +What is JEDI Cloud? +JEDI Cloud is a DoD enterprise-wide solution for commercial cloud services. + +What is a JEDI Cloud Workspace? +A JEDI Cloud Workspace is where you may access and manage the cloud resources associated with your projects and environments. + +JEDI Cloud is managed by the Cloud Computing Program Office. Learn more at jedi.cloud. diff --git a/tests/domain/test_invitations.py b/tests/domain/test_invitations.py new file mode 100644 index 00000000..2ec08a9f --- /dev/null +++ b/tests/domain/test_invitations.py @@ -0,0 +1,62 @@ +import datetime +import pytest +import re + +from atst.domain.invitations import Invitations, InvitationError +from atst.models.invitation import Status + +from tests.factories import ( + WorkspaceFactory, + WorkspaceRoleFactory, + UserFactory, + InvitationFactory, +) + + +def test_create_invitation(): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace) + invite = Invitations.create(ws_role, workspace.owner, user) + assert invite.user == user + assert invite.workspace_role == ws_role + assert invite.inviter == workspace.owner + assert invite.status == Status.PENDING + assert re.match(r"^[\w\-_]+$", invite.token) + + +def test_accept_invitation(): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace) + invite = Invitations.create(ws_role, workspace.owner, user) + assert invite.is_pending + accepted_invite = Invitations.accept(invite.token) + assert accepted_invite.is_accepted + + +def test_accept_expired_invitation(): + user = UserFactory.create() + increment = Invitations.EXPIRATION_LIMIT_MINUTES + 1 + expiration_time = datetime.datetime.now() - datetime.timedelta(minutes=increment) + invite = InvitationFactory.create( + user_id=user.id, expiration_time=expiration_time, status=Status.PENDING + ) + with pytest.raises(InvitationError): + Invitations.accept(invite.token) + + assert invite.is_rejected + + +def test_accept_rejected_invite(): + user = UserFactory.create() + invite = InvitationFactory.create(user_id=user.id, status=Status.REJECTED) + with pytest.raises(InvitationError): + Invitations.accept(invite.token) + + +def test_accept_revoked_invite(): + user = UserFactory.create() + invite = InvitationFactory.create(user_id=user.id, status=Status.REVOKED) + with pytest.raises(InvitationError): + Invitations.accept(invite.token) diff --git a/tests/domain/test_workspace_users.py b/tests/domain/test_workspace_users.py index 6e3aa710..c8d6aa5d 100644 --- a/tests/domain/test_workspace_users.py +++ b/tests/domain/test_workspace_users.py @@ -1,6 +1,14 @@ from atst.domain.workspace_users import WorkspaceUsers from atst.domain.users import Users -from tests.factories import WorkspaceFactory, UserFactory +from atst.models.workspace_role import Status as WorkspaceRoleStatus +from atst.domain.roles import Roles + +from tests.factories import ( + WorkspaceFactory, + UserFactory, + InvitationFactory, + WorkspaceRoleFactory, +) def test_can_create_new_workspace_user(): @@ -34,3 +42,24 @@ def test_can_update_existing_workspace_user(): workspace_users[0].workspace_role.role.name == new_user.workspace_roles[0].role.name ) + + +def test_workspace_user_permissions(): + workspace_one = WorkspaceFactory.create() + workspace_two = WorkspaceFactory.create() + new_user = UserFactory.create() + WorkspaceRoleFactory.create( + workspace=workspace_one, + user=new_user, + role=Roles.get("developer"), + status=WorkspaceRoleStatus.ACTIVE, + ) + WorkspaceRoleFactory.create( + workspace=workspace_two, + user=new_user, + role=Roles.get("developer"), + status=WorkspaceRoleStatus.PENDING, + ) + + assert WorkspaceUsers.workspace_user_permissions(workspace_one, new_user) + assert not WorkspaceUsers.workspace_user_permissions(workspace_two, new_user) diff --git a/tests/domain/test_workspaces.py b/tests/domain/test_workspaces.py index 86064c1e..97e13ad8 100644 --- a/tests/domain/test_workspaces.py +++ b/tests/domain/test_workspaces.py @@ -6,8 +6,14 @@ from atst.domain.workspaces import Workspaces from atst.domain.workspace_users import WorkspaceUsers from atst.domain.projects import Projects from atst.domain.environments import Environments +from atst.models.workspace_role import Status as WorkspaceRoleStatus -from tests.factories import RequestFactory, UserFactory +from tests.factories import ( + RequestFactory, + UserFactory, + InvitationFactory, + WorkspaceRoleFactory, +) @pytest.fixture(scope="function") @@ -86,6 +92,23 @@ def test_can_create_workspace_user(workspace, workspace_owner): new_member = Workspaces.create_member(workspace_owner, workspace, user_data) assert new_member.workspace == workspace + assert new_member.user.provisional + + +def test_can_add_existing_user_to_workspace(workspace, workspace_owner): + user = UserFactory.create() + user_data = { + "first_name": "New", + "last_name": "User", + "email": "new.user@mail.com", + "workspace_role": "developer", + "dod_id": user.dod_id, + } + + new_member = Workspaces.create_member(workspace_owner, workspace, user_data) + assert new_member.workspace == workspace + assert new_member.user.email == user.email + assert not new_member.user.provisional def test_need_permission_to_create_workspace_user(workspace, workspace_owner): @@ -199,9 +222,10 @@ def test_scoped_workspace_returns_all_projects_for_workspace_admin( ["dev", "staging", "prod"], ) - admin = Workspaces.add_member( - workspace, UserFactory.from_atat_role("default"), "admin" - ).user + admin = UserFactory.from_atat_role("default") + Workspaces._create_workspace_role( + admin, workspace, "admin", status=WorkspaceRoleStatus.ACTIVE + ) scoped_workspace = Workspaces.get(admin, workspace.id) assert len(scoped_workspace.projects) == 5 @@ -226,13 +250,25 @@ def test_scoped_workspace_returns_all_projects_for_workspace_owner( assert len(scoped_workspace.projects[0].environments) == 3 -def test_for_user_returns_assigned_workspaces_for_user(workspace, workspace_owner): +def test_for_user_returns_active_workspaces_for_user(workspace, workspace_owner): + bob = UserFactory.from_atat_role("default") + WorkspaceRoleFactory.create( + user=bob, workspace=workspace, status=WorkspaceRoleStatus.ACTIVE + ) + Workspaces.create(RequestFactory.create()) + + bobs_workspaces = Workspaces.for_user(bob) + + assert len(bobs_workspaces) == 1 + + +def test_for_user_does_not_return_inactive_workspaces(workspace, workspace_owner): bob = UserFactory.from_atat_role("default") Workspaces.add_member(workspace, bob, "developer") Workspaces.create(RequestFactory.create()) bobs_workspaces = Workspaces.for_user(bob) - assert len(bobs_workspaces) == 1 + assert len(bobs_workspaces) == 0 def test_for_user_returns_all_workspaces_for_ccpo(workspace, workspace_owner): @@ -250,7 +286,9 @@ def test_get_for_update_information(): assert workspace == owner_ws admin = UserFactory.create() - Workspaces.add_member(workspace, admin, "admin") + Workspaces._create_workspace_role( + admin, workspace, "admin", status=WorkspaceRoleStatus.ACTIVE + ) admin_ws = Workspaces.get_for_update_information(admin, workspace.id) assert workspace == admin_ws diff --git a/tests/factories.py b/tests/factories.py index b2338a06..f790579b 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -18,8 +18,11 @@ from atst.models.user import User from atst.models.role import Role from atst.models.workspace import Workspace from atst.domain.roles import Roles -from atst.models.workspace_role import WorkspaceRole +from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus from atst.models.environment_role import EnvironmentRole +from atst.models.invitation import Invitation, Status as InvitationStatus +from atst.domain.workspaces import Workspaces +from atst.domain.invitations import Invitations class Base(factory.alchemy.SQLAlchemyModelFactory): @@ -243,7 +246,7 @@ class WorkspaceFactory(Base): @classmethod def _create(cls, model_class, *args, **kwargs): with_projects = kwargs.pop("projects", []) - owner = kwargs.pop("owner", None) + owner = kwargs.pop("owner", UserFactory.create()) members = kwargs.pop("members", []) workspace = super()._create(model_class, *args, **kwargs) @@ -252,17 +255,22 @@ class WorkspaceFactory(Base): ProjectFactory.create(workspace=workspace, **p) for p in with_projects ] - if owner: - workspace.request.creator = owner - WorkspaceRoleFactory.create( - workspace=workspace, role=Roles.get("owner"), user=owner - ) + workspace.request.creator = owner + WorkspaceRoleFactory.create( + workspace=workspace, + role=Roles.get("owner"), + user=owner, + status=WorkspaceRoleStatus.ACTIVE, + ) for member in members: user = member.get("user", UserFactory.create()) role_name = member["role_name"] WorkspaceRoleFactory.create( - workspace=workspace, role=Roles.get(role_name), user=user + workspace=workspace, + role=Roles.get(role_name), + user=user, + status=WorkspaceRoleStatus.ACTIVE, ) workspace.projects = projects @@ -325,3 +333,11 @@ class EnvironmentRoleFactory(Base): environment = factory.SubFactory(EnvironmentFactory) role = factory.Faker("name") user = factory.SubFactory(UserFactory) + + +class InvitationFactory(Base): + class Meta: + model = Invitation + + status = InvitationStatus.PENDING + expiration_time = Invitations.current_expiration_time() diff --git a/tests/routes/test_home.py b/tests/routes/test_home.py index 3bcc381d..83ac2eef 100644 --- a/tests/routes/test_home.py +++ b/tests/routes/test_home.py @@ -3,11 +3,8 @@ from atst.domain.workspaces import Workspaces def test_user_with_workspaces_has_workspaces_nav(client, user_session): - user = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(user, workspace, "developer") - - user_session(user) + user_session(workspace.owner) response = client.get("/home", follow_redirects=True) assert b'href="/workspaces"' in response.data diff --git a/tests/routes/test_workspaces.py b/tests/routes/test_workspaces.py index f345f462..d4f543de 100644 --- a/tests/routes/test_workspaces.py +++ b/tests/routes/test_workspaces.py @@ -1,20 +1,25 @@ from flask import url_for -from tests.factories import UserFactory, WorkspaceFactory +from tests.factories import ( + UserFactory, + WorkspaceFactory, + WorkspaceRoleFactory, + InvitationFactory, +) from atst.domain.workspaces import Workspaces from atst.domain.workspace_users import WorkspaceUsers from atst.domain.projects import Projects from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles from atst.models.workspace_user import WorkspaceUser +from atst.models.workspace_role import Status as WorkspaceRoleStatus +from atst.models.invitation import Status as InvitationStatus +from atst.queue import queue def test_user_with_permission_has_budget_report_link(client, user_session): - user = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(user, workspace, "owner") - - user_session(user) + user_session(workspace.owner) response = client.get("/workspaces/{}/projects".format(workspace.id)) assert ( 'href="/workspaces/{}/reports"'.format(workspace.id).encode() in response.data @@ -24,7 +29,9 @@ def test_user_with_permission_has_budget_report_link(client, user_session): def test_user_without_permission_has_no_budget_report_link(client, user_session): user = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(user, workspace, "developer") + Workspaces._create_workspace_role( + user, workspace, "developer", status=WorkspaceRoleStatus.ACTIVE + ) user_session(user) response = client.get("/workspaces/{}/projects".format(workspace.id)) assert ( @@ -34,11 +41,8 @@ def test_user_without_permission_has_no_budget_report_link(client, user_session) def test_user_with_permission_has_add_project_link(client, user_session): - user = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(user, workspace, "owner") - - user_session(user) + user_session(workspace.owner) response = client.get("/workspaces/{}/projects".format(workspace.id)) assert ( 'href="/workspaces/{}/projects/new"'.format(workspace.id).encode() @@ -59,11 +63,8 @@ def test_user_without_permission_has_no_add_project_link(client, user_session): def test_user_with_permission_has_add_member_link(client, user_session): - user = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(user, workspace, "owner") - - user_session(user) + user_session(workspace.owner) response = client.get("/workspaces/{}/members".format(workspace.id)) assert ( 'href="/workspaces/{}/members/new"'.format(workspace.id).encode() @@ -84,10 +85,8 @@ def test_user_without_permission_has_no_add_member_link(client, user_session): def test_update_workspace_name(client, user_session): - user = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(user, workspace, "admin") - user_session(user) + user_session(workspace.owner) response = client.post( url_for("workspaces.edit_workspace", workspace_id=workspace.id), data={"name": "a cool new name"}, @@ -98,17 +97,15 @@ def test_update_workspace_name(client, user_session): def test_view_edit_project(client, user_session): - owner = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(owner, workspace, "admin") project = Projects.create( - owner, + workspace.owner, workspace, "Snazzy Project", "A new project for me and my friends", {"env1", "env2"}, ) - user_session(owner) + user_session(workspace.owner) response = client.get( "/workspaces/{}/projects/{}/edit".format(workspace.id, project.id) ) @@ -176,11 +173,11 @@ def test_user_without_permission_cannot_update_project(client, user_session): def test_create_member(client, user_session): - owner = UserFactory.create() user = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(owner, workspace, "admin") - user_session(owner) + user_session(workspace.owner) + queue_length = len(queue.get_queue()) + response = client.post( url_for("workspaces.create_member", workspace_id=workspace.id), data={ @@ -195,6 +192,8 @@ def test_create_member(client, user_session): assert response.status_code == 200 assert user.has_workspaces + assert user.invitations + assert len(queue.get_queue()) == queue_length + 1 def test_permissions_for_view_member(client, user_session): @@ -211,12 +210,10 @@ def test_permissions_for_view_member(client, user_session): def test_update_member_workspace_role(client, user_session): - owner = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(owner, workspace, "admin") user = UserFactory.create() member = WorkspaceUsers.add(user, workspace.id, "developer") - user_session(owner) + user_session(workspace.owner) response = client.post( url_for( "workspaces.update_member", workspace_id=workspace.id, member_id=user.id @@ -229,12 +226,10 @@ def test_update_member_workspace_role(client, user_session): def test_update_member_workspace_role_with_no_data(client, user_session): - owner = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(owner, workspace, "admin") user = UserFactory.create() member = WorkspaceUsers.add(user, workspace.id, "developer") - user_session(owner) + user_session(workspace.owner) response = client.post( url_for( "workspaces.update_member", workspace_id=workspace.id, member_id=user.id @@ -247,14 +242,11 @@ def test_update_member_workspace_role_with_no_data(client, user_session): def test_update_member_environment_role(client, user_session): - owner = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(owner, workspace, "admin") - user = UserFactory.create() member = WorkspaceUsers.add(user, workspace.id, "developer") project = Projects.create( - owner, + workspace.owner, workspace, "Snazzy Project", "A new project for me and my friends", @@ -264,7 +256,7 @@ def test_update_member_environment_role(client, user_session): env2_id = project.environments[1].id for env in project.environments: Environments.add_member(env, user, "developer") - user_session(owner) + user_session(workspace.owner) response = client.post( url_for( "workspaces.update_member", workspace_id=workspace.id, member_id=user.id @@ -282,14 +274,11 @@ def test_update_member_environment_role(client, user_session): def test_update_member_environment_role_with_no_data(client, user_session): - owner = UserFactory.create() workspace = WorkspaceFactory.create() - Workspaces._create_workspace_role(owner, workspace, "admin") - user = UserFactory.create() member = WorkspaceUsers.add(user, workspace.id, "developer") project = Projects.create( - owner, + workspace.owner, workspace, "Snazzy Project", "A new project for me and my friends", @@ -298,7 +287,7 @@ def test_update_member_environment_role_with_no_data(client, user_session): env1_id = project.environments[0].id for env in project.environments: Environments.add_member(env, user, "developer") - user_session(owner) + user_session(workspace.owner) response = client.post( url_for( "workspaces.update_member", workspace_id=workspace.id, member_id=user.id @@ -308,3 +297,61 @@ def test_update_member_environment_role_with_no_data(client, user_session): ) assert response.status_code == 200 assert EnvironmentRoles.get(user.id, env1_id).role == "developer" + + +def test_new_member_accepts_valid_invite(client, user_session): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + ws_role = WorkspaceRoleFactory.create( + workspace=workspace, user=user, status=WorkspaceRoleStatus.PENDING + ) + invite = InvitationFactory.create(user_id=user.id, workspace_role_id=ws_role.id) + + # the user does not have access to the workspace before accepting the invite + assert len(Workspaces.for_user(user)) == 0 + + user_session(user) + response = client.get(url_for("workspaces.accept_invitation", token=invite.token)) + + # user is redirected to the workspace view + assert response.status_code == 302 + assert ( + url_for("workspaces.show_workspace", workspace_id=invite.workspace.id) + in response.headers["Location"] + ) + # the one-time use invite is no longer usable + assert invite.is_accepted + # the user has access to the workspace + assert len(Workspaces.for_user(user)) == 1 + + +def test_new_member_accept_invalid_invite(client, user_session): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + ws_role = WorkspaceRoleFactory.create( + user=user, workspace=workspace, status=WorkspaceRoleStatus.PENDING + ) + invite = InvitationFactory.create( + user_id=user.id, workspace_role_id=ws_role.id, status=InvitationStatus.REJECTED + ) + user_session(user) + response = client.get(url_for("workspaces.accept_invitation", token=invite.token)) + + assert response.status_code == 404 + + +def test_user_who_has_not_accepted_workspace_invite_cannot_view(client, user_session): + user = UserFactory.create() + workspace = WorkspaceFactory.create() + + # create user in workspace with invitation + user_session(workspace.owner) + response = client.post( + url_for("workspaces.create_member", workspace_id=workspace.id), + data={"workspace_role": "developer", **user.to_dictionary()}, + ) + + # user tries to view workspace before accepting invitation + user_session(user) + response = client.get("/workspaces/{}/projects".format(workspace.id)) + assert response.status_code == 404