Allow only organization admins to export application (#5085)

* Added permission export:applications for admin role

* Only admins are allowed to export applications

Co-authored-by: Pranav Kanade <pranav@appsmith.com>
This commit is contained in:
Abhijeet 2021-06-15 18:18:21 +05:30 committed by GitHub
parent 32dffd0b58
commit 7c430303aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 251 additions and 20 deletions

View File

@ -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();
});
});

View File

@ -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 &&

View File

@ -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",

View File

@ -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),

View File

@ -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",

View File

@ -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);

View File

@ -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<Organization> organizations = mongoTemplate.find(
query(where("userRoles").exists(true)),
Organization.class
);
for (final Organization organization : organizations) {
Set<String> 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<String> exportApplicationPermissionUsernames = new HashSet<>();
exportApplicationPermissionUsernames.addAll(adminUsernames);
Set<Policy> policies = organization.getPolicies();
if (policies == null) {
policies = new HashSet<>();
}
Optional<Policy> 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<Application> orgApplications = mongoTemplate.find(
query(where(fieldName(QApplication.application.organizationId)).is(organization.getId())),
Application.class
);
for (final Application application : orgApplications) {
Set<Policy> applicationPolicies = application.getPolicies();
if (applicationPolicies == null) {
applicationPolicies = new HashSet<>();
}
Optional<Policy> 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);
}
}
}
}

View File

@ -92,17 +92,20 @@ public class ImportExportApplicationService {
return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.APPLICATION_ID));
}
Mono<Application> 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)

View File

@ -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<ApplicationJson> 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<ApplicationJson> resultMono = applicationPageService.createApplication(testApplication, orgId)
.flatMap(application -> importExportApplicationService.exportApplicationById(application.getId()));
final Mono<ApplicationJson> 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<DataBuffer> 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);