diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/ExportApplication_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/ExportApplication_spec.js index 1c8bbc29a0..3b58ec8dde 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/ExportApplication_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Applications/ExportApplication_spec.js @@ -3,13 +3,19 @@ const homePage = require("../../../../locators/HomePage.json"); const commonlocators = require("../../../../locators/commonlocators.json"); describe("Export application as a JSON file", function() { + let orgid; + let appid; + let currentUrl; + let newOrganizationName; + let appname; + before(() => { cy.addDsl(dsl); }); it("Check if exporting app flow works as expected", function() { cy.get(commonlocators.homeIcon).click({ force: true }); - const appname = localStorage.getItem("AppName"); + appname = localStorage.getItem("AppName"); cy.get(homePage.searchInput).type(appname); // eslint-disable-next-line cypress/no-unnecessary-waiting cy.wait(2000); @@ -22,5 +28,132 @@ describe("Export application as a JSON file", function() { .click({ force: true }); cy.get(homePage.exportAppFromMenu).click({ force: true }); cy.get(homePage.toastMessage).should("contain", "Successfully exported"); + cy.LogOut(); + }); + + it("User with admin access,should be able to export the app", function() { + cy.LogintoApp(Cypress.env("USERNAME"), Cypress.env("PASSWORD")); + cy.NavigateToHome(); + cy.generateUUID().then((uid) => { + orgid = uid; + appid = uid; + localStorage.setItem("OrgName", orgid); + cy.createOrg(); + cy.wait("@createOrg").then((interception) => { + newOrganizationName = interception.response.body.data.name; + cy.renameOrg(newOrganizationName, orgid); + }); + cy.CreateAppForOrg(orgid, appid); + cy.wait("@getPagesForCreateApp").should( + "have.nested.property", + "response.body.responseMeta.status", + 200, + ); + cy.get("h2").contains("Drag and drop a widget here"); + cy.get(homePage.shareApp).click({ force: true }); + cy.shareApp(Cypress.env("TESTUSERNAME1"), homePage.adminRole); + + cy.LogOut(); + + cy.LogintoApp(Cypress.env("TESTUSERNAME1"), Cypress.env("TESTPASSWORD1")); + cy.NavigateToHome(); + cy.wait(2000); + cy.log({ appid }); + cy.get(homePage.searchInput).type(appid); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + + cy.get(homePage.applicationCard) + .first() + .trigger("mouseover"); + cy.get(homePage.appMoreIcon) + .first() + .click({ force: true }); + cy.get(homePage.exportAppFromMenu).should("be.visible"); + }); + cy.LogOut(); + }); + + it("User with developer access,should not be able to export the app", function() { + cy.LogintoApp(Cypress.env("USERNAME"), Cypress.env("PASSWORD")); + cy.NavigateToHome(); + cy.generateUUID().then((uid) => { + orgid = uid; + appid = uid; + localStorage.setItem("OrgName", orgid); + cy.createOrg(); + cy.wait("@createOrg").then((interception) => { + newOrganizationName = interception.response.body.data.name; + cy.renameOrg(newOrganizationName, orgid); + }); + cy.CreateAppForOrg(orgid, appid); + cy.wait("@getPagesForCreateApp").should( + "have.nested.property", + "response.body.responseMeta.status", + 200, + ); + cy.get("h2").contains("Drag and drop a widget here"); + cy.get(homePage.shareApp).click({ force: true }); + cy.shareApp(Cypress.env("TESTUSERNAME1"), homePage.developerRole); + + cy.LogOut(); + + cy.LogintoApp(Cypress.env("TESTUSERNAME1"), Cypress.env("TESTPASSWORD1")); + cy.NavigateToHome(); + cy.wait(2000); + cy.log({ appid }); + cy.get(homePage.searchInput).type(appid); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + + cy.get(homePage.applicationCard) + .first() + .trigger("mouseover"); + cy.get(homePage.appMoreIcon) + .first() + .click({ force: true }); + cy.get(homePage.exportAppFromMenu).should("not.exist"); + }); + cy.LogOut(); + }); + + it("User with viewer access,should not be able to export the app", function() { + cy.LogintoApp(Cypress.env("USERNAME"), Cypress.env("PASSWORD")); + cy.NavigateToHome(); + cy.generateUUID().then((uid) => { + orgid = uid; + appid = uid; + localStorage.setItem("OrgName", orgid); + cy.createOrg(); + cy.wait("@createOrg").then((interception) => { + newOrganizationName = interception.response.body.data.name; + cy.renameOrg(newOrganizationName, orgid); + }); + cy.CreateAppForOrg(orgid, appid); + cy.wait("@getPagesForCreateApp").should( + "have.nested.property", + "response.body.responseMeta.status", + 200, + ); + cy.get("h2").contains("Drag and drop a widget here"); + cy.get(homePage.shareApp).click({ force: true }); + cy.shareApp(Cypress.env("TESTUSERNAME1"), homePage.viewerRole); + + cy.LogOut(); + + cy.LogintoApp(Cypress.env("TESTUSERNAME1"), Cypress.env("TESTPASSWORD1")); + cy.NavigateToHome(); + cy.wait(2000); + cy.log({ appid }); + cy.get(homePage.searchInput).type(appid); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + + cy.get(homePage.applicationCard) + .first() + .trigger("mouseover"); + cy.get(homePage.appEditIcon).should("not.exist"); + }); + cy.LogOut(); }); }); diff --git a/app/client/src/pages/Applications/ApplicationCard.tsx b/app/client/src/pages/Applications/ApplicationCard.tsx index e7ed55a90e..c0bc403bed 100644 --- a/app/client/src/pages/Applications/ApplicationCard.tsx +++ b/app/client/src/pages/Applications/ApplicationCard.tsx @@ -300,7 +300,7 @@ export function ApplicationCard(props: ApplicationCardProps) { cypressSelector: "t--fork-app", }); } - if (!!props.enableImportExport && hasEditPermission) { + if (!!props.enableImportExport && hasExportPermission) { moreActionItems.push({ onSelect: exportApplicationAsJSONFile, text: "Export", @@ -323,6 +323,10 @@ export function ApplicationCard(props: ApplicationCardProps) { props.application?.userPermissions ?? [], PERMISSION_TYPE.READ_APPLICATION, ); + const hasExportPermission = isPermitted( + props.application?.userPermissions ?? [], + PERMISSION_TYPE.EXPORT_APPLICATION, + ); const updateColor = (color: string) => { setSelectedColor(color); props.update && diff --git a/app/client/src/pages/Applications/permissionHelpers.tsx b/app/client/src/pages/Applications/permissionHelpers.tsx index 0aac048df2..821f92503f 100644 --- a/app/client/src/pages/Applications/permissionHelpers.tsx +++ b/app/client/src/pages/Applications/permissionHelpers.tsx @@ -2,6 +2,7 @@ export enum PERMISSION_TYPE { MANAGE_ORGANIZATION = "manage:organizations", CREATE_APPLICATION = "manage:orgApplications", MANAGE_APPLICATION = "manage:applications", + EXPORT_APPLICATION = "export:applications", READ_APPLICATION = "read:applications", READ_ORGANIZATION = "read:organizations", INVITE_USER_TO_ORGANIZATION = "inviteUsers:organization", diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/AclPermission.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/AclPermission.java index 2425036fa6..aa0b7ef0f7 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/AclPermission.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/AclPermission.java @@ -44,6 +44,7 @@ public enum AclPermission { ORGANIZATION_MANAGE_APPLICATIONS("manage:orgApplications", Organization.class), ORGANIZATION_READ_APPLICATIONS("read:orgApplications", Organization.class), ORGANIZATION_PUBLISH_APPLICATIONS("publish:orgApplications", Organization.class), + ORGANIZATION_EXPORT_APPLICATIONS("export:orgApplications", Organization.class), // Invitation related permissions ORGANIZATION_INVITE_USERS("inviteUsers:organization", Organization.class), @@ -51,6 +52,7 @@ public enum AclPermission { MANAGE_APPLICATIONS("manage:applications", Application.class), READ_APPLICATIONS("read:applications", Application.class), PUBLISH_APPLICATIONS("publish:applications", Application.class), + EXPORT_APPLICATIONS("export:applications", Application.class), // Making an application public permission at Organization level MAKE_PUBLIC_APPLICATIONS("makePublic:applications", Application.class), diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/AppsmithRole.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/AppsmithRole.java index d1a80ec2fc..7138666b93 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/AppsmithRole.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/AppsmithRole.java @@ -8,6 +8,7 @@ import java.util.Set; import static com.appsmith.server.acl.AclPermission.MANAGE_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.MANAGE_ORGANIZATIONS; +import static com.appsmith.server.acl.AclPermission.ORGANIZATION_EXPORT_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.ORGANIZATION_INVITE_USERS; import static com.appsmith.server.acl.AclPermission.ORGANIZATION_MANAGE_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.ORGANIZATION_PUBLISH_APPLICATIONS; @@ -19,10 +20,12 @@ import static com.appsmith.server.acl.AclPermission.READ_ORGANIZATIONS; public enum AppsmithRole { APPLICATION_ADMIN("Application Administrator", "", Set.of(MANAGE_APPLICATIONS)), APPLICATION_VIEWER("Application Viewer", "", Set.of(READ_APPLICATIONS)), - ORGANIZATION_ADMIN("Administrator", "Can modify all organization settings including editing applications and inviting other users to the organization", - Set.of(MANAGE_ORGANIZATIONS, ORGANIZATION_INVITE_USERS)), - ORGANIZATION_DEVELOPER("Developer", "Can edit and view applications along with inviting other users to the organization", Set.of(READ_ORGANIZATIONS, - ORGANIZATION_MANAGE_APPLICATIONS, ORGANIZATION_READ_APPLICATIONS, ORGANIZATION_PUBLISH_APPLICATIONS, ORGANIZATION_INVITE_USERS)), + ORGANIZATION_ADMIN("Administrator", "Can modify all organization settings including editing applications, " + + "inviting other users to the organization and exporting applications from the organization", + Set.of(MANAGE_ORGANIZATIONS, ORGANIZATION_INVITE_USERS, ORGANIZATION_EXPORT_APPLICATIONS)), + ORGANIZATION_DEVELOPER("Developer", "Can edit and view applications along with inviting other users to the organization", + Set.of(READ_ORGANIZATIONS, ORGANIZATION_MANAGE_APPLICATIONS, ORGANIZATION_READ_APPLICATIONS, + ORGANIZATION_PUBLISH_APPLICATIONS, ORGANIZATION_INVITE_USERS)), ORGANIZATION_VIEWER( "App Viewer", "Can view applications and invite other users to view applications", diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/PolicyGenerator.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/PolicyGenerator.java index 3b6112a892..cd5652f61e 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/PolicyGenerator.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/acl/PolicyGenerator.java @@ -23,6 +23,7 @@ import static com.appsmith.server.acl.AclPermission.COMMENT_ON_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.COMMENT_ON_THREAD; import static com.appsmith.server.acl.AclPermission.EXECUTE_ACTIONS; import static com.appsmith.server.acl.AclPermission.EXECUTE_DATASOURCES; +import static com.appsmith.server.acl.AclPermission.EXPORT_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.MAKE_PUBLIC_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.MANAGE_ACTIONS; import static com.appsmith.server.acl.AclPermission.MANAGE_APPLICATIONS; @@ -30,6 +31,7 @@ import static com.appsmith.server.acl.AclPermission.MANAGE_DATASOURCES; import static com.appsmith.server.acl.AclPermission.MANAGE_ORGANIZATIONS; import static com.appsmith.server.acl.AclPermission.MANAGE_PAGES; import static com.appsmith.server.acl.AclPermission.MANAGE_USERS; +import static com.appsmith.server.acl.AclPermission.ORGANIZATION_EXPORT_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.ORGANIZATION_MANAGE_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.ORGANIZATION_PUBLISH_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.ORGANIZATION_READ_APPLICATIONS; @@ -115,6 +117,7 @@ public class PolicyGenerator { hierarchyGraph.addEdge(ORGANIZATION_READ_APPLICATIONS, READ_APPLICATIONS); hierarchyGraph.addEdge(ORGANIZATION_PUBLISH_APPLICATIONS, PUBLISH_APPLICATIONS); hierarchyGraph.addEdge(MANAGE_ORGANIZATIONS, MAKE_PUBLIC_APPLICATIONS); + hierarchyGraph.addEdge(ORGANIZATION_EXPORT_APPLICATIONS, EXPORT_APPLICATIONS); // If the user is being given MANAGE_APPLICATION permission, they must also be given READ_APPLICATION perm lateralGraph.addEdge(MANAGE_APPLICATIONS, READ_APPLICATIONS); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java index ae884e8c3e..3ca1ab4cc6 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java @@ -99,7 +99,9 @@ import java.util.stream.Collectors; import static com.appsmith.external.helpers.BeanCopyUtils.copyNewFieldValuesIntoOldObject; import static com.appsmith.server.acl.AclPermission.EXECUTE_ACTIONS; +import static com.appsmith.server.acl.AclPermission.EXPORT_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.MAKE_PUBLIC_APPLICATIONS; +import static com.appsmith.server.acl.AclPermission.ORGANIZATION_EXPORT_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.ORGANIZATION_INVITE_USERS; import static com.appsmith.server.acl.AclPermission.READ_ACTIONS; import static com.appsmith.server.helpers.CollectionUtils.isNullOrEmpty; @@ -2386,4 +2388,75 @@ public class DatabaseChangelog { firestoreActionQueries.stream() .forEach(action -> mongoTemplate.save(action)); } + + @ChangeSet(order = "071", id = "add-application-export-permissions", author = "") + public void addApplicationExportPermissions(MongoTemplate mongoTemplate) { + final List organizations = mongoTemplate.find( + query(where("userRoles").exists(true)), + Organization.class + ); + + for (final Organization organization : organizations) { + Set adminUsernames = organization.getUserRoles() + .stream() + .filter(role -> (role.getRole().equals(AppsmithRole.ORGANIZATION_ADMIN))) + .map(role -> role.getUsername()) + .collect(Collectors.toSet()); + + if (adminUsernames.isEmpty()) { + continue; + } + // All the administrators of the organization should be allowed to export applications permission + Set exportApplicationPermissionUsernames = new HashSet<>(); + exportApplicationPermissionUsernames.addAll(adminUsernames); + + Set policies = organization.getPolicies(); + if (policies == null) { + policies = new HashSet<>(); + } + + Optional exportAppOrgLevelOptional = policies.stream() + .filter(policy -> policy.getPermission().equals(ORGANIZATION_EXPORT_APPLICATIONS.getValue())).findFirst(); + + if (exportAppOrgLevelOptional.isPresent()) { + Policy exportApplicationPolicy = exportAppOrgLevelOptional.get(); + exportApplicationPolicy.getUsers().addAll(exportApplicationPermissionUsernames); + } else { + // this policy doesnt exist. create and add this to the policy set + Policy inviteUserPolicy = Policy.builder().permission(ORGANIZATION_EXPORT_APPLICATIONS.getValue()) + .users(exportApplicationPermissionUsernames).build(); + organization.getPolicies().add(inviteUserPolicy); + } + + mongoTemplate.save(organization); + + // Update the applications with export applications policy for all administrators of the organization + List orgApplications = mongoTemplate.find( + query(where(fieldName(QApplication.application.organizationId)).is(organization.getId())), + Application.class + ); + + for (final Application application : orgApplications) { + Set applicationPolicies = application.getPolicies(); + if (applicationPolicies == null) { + applicationPolicies = new HashSet<>(); + } + + Optional exportAppOptional = applicationPolicies.stream() + .filter(policy -> policy.getPermission().equals(EXPORT_APPLICATIONS.getValue())).findFirst(); + + if (exportAppOptional.isPresent()) { + Policy exportAppPolicy = exportAppOptional.get(); + exportAppPolicy.getUsers().addAll(adminUsernames); + } else { + // this policy doesn't exist, create and add this to the policy set + Policy newExportAppPolicy = Policy.builder().permission(EXPORT_APPLICATIONS.getValue()) + .users(adminUsernames).build(); + application.getPolicies().add(newExportAppPolicy); + } + + mongoTemplate.save(application); + } + } + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ImportExportApplicationService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ImportExportApplicationService.java index fe4a597d75..57bdce876c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ImportExportApplicationService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ImportExportApplicationService.java @@ -92,17 +92,20 @@ public class ImportExportApplicationService { return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.APPLICATION_ID)); } + Mono applicationMono = applicationService.findById(applicationId, AclPermission.EXPORT_APPLICATIONS) + .switchIfEmpty(Mono.error( + new AppsmithException(AppsmithError.ACL_NO_RESOURCE_FOUND, FieldName.APPLICATION_ID, applicationId)) + ); + return pluginRepository .findAll() .map(plugin -> { pluginMap.put(plugin.getId(), plugin.getPackageName()); return plugin; }) - .then(applicationService.findById(applicationId, AclPermission.MANAGE_APPLICATIONS)) - .switchIfEmpty(Mono.error(new AppsmithException( - AppsmithError.ACL_NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId)) - ) + .then(applicationMono) .flatMap(application -> { + ApplicationPage unpublishedDefaultPage = application.getPages() .stream() .filter(ApplicationPage::getIsDefault) diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ImportExportApplicationServiceTests.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ImportExportApplicationServiceTests.java index a18f2ab5e6..f788a28a36 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ImportExportApplicationServiceTests.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ImportExportApplicationServiceTests.java @@ -17,7 +17,6 @@ import com.appsmith.server.domains.NewPage; import com.appsmith.server.domains.Organization; import com.appsmith.server.domains.Plugin; import com.appsmith.server.domains.PluginType; -import com.appsmith.server.domains.User; import com.appsmith.server.dtos.ActionDTO; import com.appsmith.server.dtos.PageDTO; import com.appsmith.server.exceptions.AppsmithError; @@ -67,6 +66,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import static com.appsmith.server.acl.AclPermission.EXPORT_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.MANAGE_APPLICATIONS; import static com.appsmith.server.acl.AclPermission.MANAGE_DATASOURCES; import static com.appsmith.server.acl.AclPermission.MANAGE_PAGES; @@ -139,9 +139,18 @@ public class ImportExportApplicationServiceTests { public void setup() { Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())).thenReturn(Mono.just(new MockPluginExecutor())); installedPlugin = pluginRepository.findByPackageName("installed-plugin").block(); - User apiUser = userService.findByEmail("api_user").block(); - orgId = apiUser.getOrganizationIds().iterator().next(); - + + Organization organization = new Organization(); + organization.setName("Import-Export-Test-Organization"); + Organization savedOrganization = organizationService.create(organization).block(); + orgId = savedOrganization.getId(); + + Application testApplication = new Application(); + testApplication.setName("Export-Application-Test-Application"); + testApplication.setOrganizationId(orgId); + Application savedApplication = applicationPageService.createApplication(testApplication, orgId).block(); + testAppId = savedApplication.getId(); + invalid_json_file = importExportApplicationService.INVALID_JSON_FILE; Datasource ds1 = new Datasource(); @@ -168,6 +177,7 @@ public class ImportExportApplicationServiceTests { } @Test + @WithUserDetails(value = "api_user") public void exportApplicationWithNullApplicationIdTest() { Mono resultMono = importExportApplicationService.exportApplicationById(null); @@ -182,11 +192,7 @@ public class ImportExportApplicationServiceTests { @WithUserDetails(value = "api_user") public void createExportAppJsonWithoutActionsAndDatasourceTest() { - Application testApplication = new Application(); - testApplication.setName("Export Application TestApp"); - - final Mono resultMono = applicationPageService.createApplication(testApplication, orgId) - .flatMap(application -> importExportApplicationService.exportApplicationById(application.getId())); + final Mono resultMono = importExportApplicationService.exportApplicationById(testAppId); StepVerifier.create(resultMono) .assertNext(applicationJson -> { @@ -197,10 +203,11 @@ public class ImportExportApplicationServiceTests { NewPage defaultPage = pageList.get(0); - assertThat(exportedApp.getName()).isEqualTo(testApplication.getName()); + assertThat(exportedApp.getId()).isNull(); assertThat(exportedApp.getOrganizationId()).isNull(); assertThat(exportedApp.getPages()).isNull(); assertThat(exportedApp.getPolicies().size()).isEqualTo(0); + assertThat(exportedApp.getUserPermissions()).contains(EXPORT_APPLICATIONS.getValue()); assertThat(pageList.isEmpty()).isFalse(); assertThat(defaultPage.getApplicationId()).isNull(); @@ -378,6 +385,7 @@ public class ImportExportApplicationServiceTests { } @Test + @WithUserDetails(value = "api_user") public void importApplicationFromInvalidFileTest() { FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS); Flux dataBufferFlux = DataBufferUtils @@ -396,6 +404,7 @@ public class ImportExportApplicationServiceTests { } @Test + @WithUserDetails(value = "api_user") public void importApplicationWithNullOrganizationIdTest() { FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS);