diff --git a/atst/domain/application_roles.py b/atst/domain/application_roles.py index f1aa36f7..af000706 100644 --- a/atst/domain/application_roles.py +++ b/atst/domain/application_roles.py @@ -1,5 +1,5 @@ from atst.database import db -from atst.models.application_role import ApplicationRole +from atst.models import ApplicationRole, ApplicationRoleStatus from atst.domain.permission_sets import PermissionSets @@ -21,3 +21,10 @@ class ApplicationRoles(object): db.session.commit() return application_role + + @classmethod + def enable(cls, role): + role.status = ApplicationRoleStatus.ACTIVE + + db.session.add(role) + db.session.commit() diff --git a/atst/domain/applications.py b/atst/domain/applications.py index 640242c0..091e8c37 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -6,9 +6,7 @@ from atst.domain.application_roles import ApplicationRoles from atst.domain.environments import Environments from atst.domain.exceptions import NotFoundError from atst.domain.users import Users -from atst.models.application import Application -from atst.models.environment import Environment -from atst.models.environment_role import EnvironmentRole +from atst.models import Application, ApplicationRole, ApplicationRoleStatus class Applications(BaseDomainClass): @@ -31,10 +29,11 @@ class Applications(BaseDomainClass): def for_user(self, user, portfolio): return ( db.session.query(Application) - .join(Environment) - .join(EnvironmentRole) + .join(ApplicationRole) .filter(Application.portfolio_id == portfolio.id) - .filter(EnvironmentRole.user_id == user.id) + .filter(ApplicationRole.application_id == Application.id) + .filter(ApplicationRole.user_id == user.id) + .filter(ApplicationRole.status == ApplicationRoleStatus.ACTIVE) .all() ) diff --git a/atst/domain/portfolios/scopes.py b/atst/domain/portfolios/scopes.py index 9d058813..2d5ebd17 100644 --- a/atst/domain/portfolios/scopes.py +++ b/atst/domain/portfolios/scopes.py @@ -1,7 +1,6 @@ from atst.domain.authz import Authorization from atst.models.permissions import Permissions from atst.domain.applications import Applications -from atst.domain.environments import Environments class ScopedResource(object): @@ -35,31 +34,6 @@ class ScopedPortfolio(ScopedResource): ) if can_view_all_applications: - applications = self.resource.applications + return self.resource.applications else: - applications = Applications.for_user(self.user, self.resource) - - return [ - ScopedApplication(self.user, application) for application in applications - ] - - -class ScopedApplication(ScopedResource): - """ - An object that obeys the same API as a Portfolio, but with the added - functionality that it only returns sub-resources (environments) - that the given user is allowed to see. - """ - - @property - def environments(self): - can_view_all_environments = Authorization.has_portfolio_permission( - self.user, self.resource.portfolio, Permissions.VIEW_ENVIRONMENT - ) - - if can_view_all_environments: - environments = self.resource.environments - else: - environments = Environments.for_user(self.user, self.resource) - - return environments + return Applications.for_user(self.user, self.resource) diff --git a/atst/models/application_role.py b/atst/models/application_role.py index fd606216..fe56a37d 100644 --- a/atst/models/application_role.py +++ b/atst/models/application_role.py @@ -62,7 +62,13 @@ class ApplicationRole( @property def history(self): - return self.get_changes() + previous_state = self.get_changes() + change_set = {} + if "status" in previous_state: + from_status = previous_state["status"][0].value + to_status = self.status.value + change_set["status"] = [from_status, to_status] + return change_set def has_permission_set(self, perm_set_name): return first_or_none( diff --git a/atst/routes/applications/__init__.py b/atst/routes/applications/__init__.py index 60f7f495..aa2e2bb6 100644 --- a/atst/routes/applications/__init__.py +++ b/atst/routes/applications/__init__.py @@ -6,6 +6,7 @@ from . import index from . import new from . import settings from . import team +from . import invitations from atst.domain.environment_roles import EnvironmentRoles from atst.domain.exceptions import UnauthorizedError from atst.domain.authz.decorator import user_can_access_decorator as user_can diff --git a/atst/routes/applications/index.py b/atst/routes/applications/index.py index d61417f4..3aebb6b1 100644 --- a/atst/routes/applications/index.py +++ b/atst/routes/applications/index.py @@ -5,7 +5,21 @@ from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.models.permissions import Permissions +def has_portfolio_applications(_user, portfolio=None, **_kwargs): + """ + If the portfolio exists and the user has access to applications + within the scoped portfolio, the user has access to the + portfolio landing page. + """ + if portfolio and portfolio.applications: + return True + + @applications_bp.route("/portfolios//applications") -@user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") +@user_can( + Permissions.VIEW_APPLICATION, + override=has_portfolio_applications, + message="view portfolio applications", +) def portfolio_applications(portfolio_id): return render_template("portfolios/applications/index.html") diff --git a/atst/routes/applications/invitations.py b/atst/routes/applications/invitations.py new file mode 100644 index 00000000..3a7c80c3 --- /dev/null +++ b/atst/routes/applications/invitations.py @@ -0,0 +1,16 @@ +from flask import redirect, url_for, g + +from . import applications_bp +from atst.domain.invitations import ApplicationInvitations + + +@applications_bp.route("/applications/invitations/", methods=["GET"]) +def accept_invitation(token): + invite = ApplicationInvitations.accept(g.current_user, token) + + return redirect( + url_for( + "applications.portfolio_applications", + portfolio_id=invite.application.portfolio_id, + ) + ) diff --git a/templates/emails/application/invitation.txt b/templates/emails/application/invitation.txt index 1d49b452..18771021 100644 --- a/templates/emails/application/invitation.txt +++ b/templates/emails/application/invitation.txt @@ -5,6 +5,6 @@ Join this JEDI Cloud Application {{ owner }} has invited you to join a JEDI Cloud Application. Login now to view or use your JEDI Cloud resources. -{# url_for("application.accept_invitation", token=token, _external=True) #} +{{ url_for("applications.accept_invitation", token=token, _external=True) }} {% endblock %} diff --git a/tests/domain/test_application_roles.py b/tests/domain/test_application_roles.py index bde4a1d5..f430c8cb 100644 --- a/tests/domain/test_application_roles.py +++ b/tests/domain/test_application_roles.py @@ -1,6 +1,8 @@ from atst.domain.application_roles import ApplicationRoles from atst.domain.permission_sets import PermissionSets -from tests.factories import UserFactory, ApplicationFactory +from atst.models import ApplicationRoleStatus + +from tests.factories import * def test_create_application_role(): @@ -18,3 +20,16 @@ def test_create_application_role(): ) assert application_role.application == application assert application_role.user == user + + +def test_enabled_application_role(): + application = ApplicationFactory.create() + user = UserFactory.create() + app_role = ApplicationRoleFactory.create( + application=application, user=user, status=ApplicationRoleStatus.DISABLED + ) + assert app_role.status == ApplicationRoleStatus.DISABLED + + ApplicationRoles.enable(app_role) + + assert app_role.status == ApplicationRoleStatus.ACTIVE diff --git a/tests/domain/test_applications.py b/tests/domain/test_applications.py index 922b813c..84aa80d4 100644 --- a/tests/domain/test_applications.py +++ b/tests/domain/test_applications.py @@ -1,7 +1,7 @@ import pytest from uuid import uuid4 -from atst.models import CSPRole +from atst.models import CSPRole, ApplicationRoleStatus from atst.domain.applications import Applications from atst.domain.permission_sets import PermissionSets from atst.domain.exceptions import NotFoundError @@ -128,3 +128,30 @@ def test_create_member(): env_roles = member_role.user.environment_roles assert len(env_roles) == 1 assert env_roles[0].environment == env1 + + +def test_for_user(): + user = UserFactory.create() + portfolio = PortfolioFactory.create() + for _x in range(4): + ApplicationFactory.create(portfolio=portfolio) + + ApplicationRoleFactory.create( + application=portfolio.applications[0], + user=user, + status=ApplicationRoleStatus.ACTIVE, + ) + ApplicationRoleFactory.create( + application=portfolio.applications[1], + user=user, + status=ApplicationRoleStatus.ACTIVE, + ) + ApplicationRoleFactory.create( + application=portfolio.applications[2], + user=user, + status=ApplicationRoleStatus.PENDING, + ) + + assert len(portfolio.applications) == 4 + user_applications = Applications.for_user(user, portfolio) + assert len(user_applications) == 2 diff --git a/tests/routes/applications/test_index.py b/tests/routes/applications/test_index.py index b3e8dc9a..614d8d89 100644 --- a/tests/routes/applications/test_index.py +++ b/tests/routes/applications/test_index.py @@ -1,13 +1,6 @@ from flask import url_for, get_flashed_messages -from tests.factories import ( - UserFactory, - PortfolioFactory, - PortfolioRoleFactory, - EnvironmentRoleFactory, - EnvironmentFactory, - ApplicationFactory, -) +from tests.factories import * from atst.domain.applications import Applications from atst.domain.portfolios import Portfolios @@ -68,3 +61,31 @@ def test_user_without_permission_has_no_add_application_link(client, user_sessio url_for("applications.create", portfolio_id=portfolio.id) not in response.data.decode() ) + + +def test_portfolio_applications_user_with_application_roles(client, user_session): + user = UserFactory.create() + portfolio = PortfolioFactory.create() + + app1 = ApplicationFactory.create(portfolio=portfolio, name="X-Wing") + app2 = ApplicationFactory.create(portfolio=portfolio, name="TIE Fighter") + app3 = ApplicationFactory.create(portfolio=portfolio, name="Millenium Falcon") + + ApplicationRoleFactory.create( + application=app1, user=user, status=ApplicationRoleStatus.ACTIVE + ) + ApplicationRoleFactory.create( + application=app2, user=user, status=ApplicationRoleStatus.ACTIVE + ) + + user_session(user) + response = client.get( + url_for("applications.portfolio_applications", portfolio_id=portfolio.id) + ) + assert response.status_code == 200 + + body = response.data.decode() + + assert app1.name in body + assert app2.name in body + assert app3.name not in body diff --git a/tests/routes/applications/test_invitations.py b/tests/routes/applications/test_invitations.py new file mode 100644 index 00000000..9ca6d23b --- /dev/null +++ b/tests/routes/applications/test_invitations.py @@ -0,0 +1,41 @@ +from flask import url_for + +from tests.factories import * + + +def test_accept_application_invitation(client, user_session): + user = UserFactory.create() + application = ApplicationFactory.create() + app_role = ApplicationRoleFactory.create(application=application, user=user) + invite = ApplicationInvitationFactory.create( + role=app_role, user=user, inviter=application.portfolio.owner + ) + + user_session(user) + response = client.get(url_for("applications.accept_invitation", token=invite.token)) + + assert response.status_code == 302 + expected_location = url_for( + "applications.portfolio_applications", + portfolio_id=application.portfolio_id, + _external=True, + ) + assert response.location == expected_location + + +def test_accept_application_invitation_end_to_end(client, user_session): + user = UserFactory.create() + application = ApplicationFactory.create(name="Millenium Falcon") + app_role = ApplicationRoleFactory.create(application=application, user=user) + invite = ApplicationInvitationFactory.create( + role=app_role, user=user, inviter=application.portfolio.owner + ) + + user_session(user) + response = client.get( + url_for("applications.accept_invitation", token=invite.token), + follow_redirects=True, + ) + + assert response.status_code == 200 + assert application.name in response.data.decode() diff --git a/tests/test_access.py b/tests/test_access.py index ded929e3..41f8f297 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -8,8 +8,7 @@ import atst from atst.app import make_app, make_config from atst.domain.auth import UNPROTECTED_ROUTES as _NO_LOGIN_REQUIRED from atst.domain.permission_sets import PermissionSets -from atst.models.environment_role import CSPRole -from atst.models.portfolio_role import Status as PortfolioRoleStatus +from atst.models import CSPRole, PortfolioRoleStatus, ApplicationRoleStatus from tests.factories import ( AttachmentFactory, @@ -35,6 +34,7 @@ _NO_ACCESS_CHECK_REQUIRED = _NO_LOGIN_REQUIRED + [ "users.user", # available to all users "users.update_user", # available to all users "portfolios.accept_invitation", # available to all users; access control is built into invitation logic + "applications.accept_invitation", # available to all users; access control is built into invitation logic "atst.catch_all", # available to all users "portfolios.portfolios", # the portfolios list is scoped to the user separately ] @@ -360,12 +360,18 @@ def test_portfolios_admin_access(get_url_assert_status): def test_applications_portfolio_applications_access(get_url_assert_status): ccpo = user_with(PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT) owner = user_with() + app_user = user_with() rando = user_with() portfolio = PortfolioFactory.create(owner=owner) + application = ApplicationFactory.create(portfolio=portfolio) + ApplicationRoleFactory.create( + application=application, user=app_user, status=ApplicationRoleStatus.ACTIVE + ) url = url_for("applications.portfolio_applications", portfolio_id=portfolio.id) get_url_assert_status(ccpo, url, 200) get_url_assert_status(owner, url, 200) + get_url_assert_status(app_user, url, 200) get_url_assert_status(rando, url, 404)