Skip to content

Commit

Permalink
Env Manager API and super user signup API (appsmithorg#6473)
Browse files Browse the repository at this point in the history
* Add API for env management and super user

* Add missing files

* Add API for signing up for super user

* Fix types in client code

* Add docs for env manager API

* Minor refactoring

* Remove unused updates to app startup

* Better error logging when unable to write file

Co-authored-by: Nidhi <[email protected]>

* Don't cache the user count (duh!)

Co-authored-by: Nidhi <[email protected]>
  • Loading branch information
sharat87 and nidhi-nair authored Aug 14, 2021
1 parent 0f41886 commit e20d616
Show file tree
Hide file tree
Showing 16 changed files with 372 additions and 29 deletions.
6 changes: 0 additions & 6 deletions app/client/src/constants/userConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@ type Gender = "MALE" | "FEMALE";

export type User = {
email: string;
currentOrganizationId: string;
organizationIds: string[];
applications: UserApplication[];
username: string;
name: string;
gender: Gender;
anonymousId: string;
};

export interface UserApplication {
Expand All @@ -25,12 +22,9 @@ export const CurrentUserDetailsRequestPayload = {
export const DefaultCurrentUserDetails: User = {
name: ANONYMOUS_USERNAME,
email: ANONYMOUS_USERNAME,
currentOrganizationId: "",
organizationIds: [],
username: ANONYMOUS_USERNAME,
applications: [],
gender: "MALE",
anonymousId: "anonymousId",
};

// TODO keeping it here instead of the USER_API since it leads to cyclic deps errors during tests
Expand Down
4 changes: 0 additions & 4 deletions app/client/src/selectors/organizationSelectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ export const getCurrentAppOrg = (state: AppState) => {
export const getAllUsers = (state: AppState) => state.ui.orgs.orgUsers;
export const getAllRoles = (state: AppState) => state.ui.orgs.orgRoles;

export const getUserCurrentOrgId = (state: AppState) => {
return state.ui.users.currentUser?.currentOrganizationId;
};

export const getRoles = createSelector(getRolesFromState, (roles?: OrgRole[]):
| OrgRole[]
| undefined => {
Expand Down
5 changes: 0 additions & 5 deletions app/client/src/utils/AnalyticsUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,17 +241,12 @@ class AnalyticsUtil {
const appId = getApplicationId(windowDoc.location);
if (userData) {
const { segment } = getAppsmithConfigs();
const app = (userData.applications || []).find(
(app: any) => app.id === appId,
);
let user: any = {};
if (segment.enabled && segment.apiKey) {
user = {
userId: userData.username,
email: userData.email,
currentOrgId: userData.currentOrganizationId,
appId: appId,
appName: app ? app.name : undefined,
source: "cloud",
};
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public enum AclPermission {
//Does the user have read organization permissions
USER_READ_ORGANIZATIONS("read:userOrganization", User.class),

// Does this user have permission to access Instance Config UI?
MANAGE_INSTANCE_ENV("manage:instanceEnv", User.class),

// TODO: Add these permissions to PolicyGenerator to assign them to the user when they sign up
// The following should be applied to Organization and not User
READ_USERS("read:users", User.class),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ public interface Url {
String ASSET_URL = BASE_URL + VERSION + "/assets";
String COMMENT_URL = BASE_URL + VERSION + "/comments";
String NOTIFICATION_URL = BASE_URL + VERSION + "/notifications";
String INSTANCE_ADMIN_URL = BASE_URL + VERSION + "/admin";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.appsmith.server.controllers;

import com.appsmith.server.constants.Url;
import com.appsmith.server.dtos.ResponseDTO;
import com.appsmith.server.solutions.EnvManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;

import javax.validation.Valid;
import java.util.Map;

@RestController
@RequestMapping(Url.INSTANCE_ADMIN_URL)
@RequiredArgsConstructor
@Slf4j
public class InstanceAdminController {

private final EnvManager envManager;

@PutMapping("/env")
public Mono<ResponseDTO<Boolean>> saveEnvChanges(
@Valid @RequestBody Map<String, String> changes
) {
log.debug("Applying env updates {}", changes);
return envManager.applyChanges(changes)
.thenReturn(new ResponseDTO<>(HttpStatus.OK.value(), true, null));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.appsmith.server.dtos.InviteUsersDTO;
import com.appsmith.server.dtos.ResetUserPasswordDTO;
import com.appsmith.server.dtos.ResponseDTO;
import com.appsmith.server.dtos.UserProfileDTO;
import com.appsmith.server.services.SessionUserService;
import com.appsmith.server.services.UserDataService;
import com.appsmith.server.services.UserOrganizationService;
Expand Down Expand Up @@ -73,6 +74,12 @@ public Mono<Void> createFormEncoded(ServerWebExchange exchange) {
return userSignup.signupAndLoginFromFormData(exchange);
}

@PostMapping("/super")
public Mono<ResponseDTO<User>> createSuperUser(@Valid @RequestBody User resource, ServerWebExchange exchange) {
return userSignup.signupAndLoginSuper(resource, exchange)
.map(created -> new ResponseDTO<>(HttpStatus.CREATED.value(), created, null));
}

@PutMapping()
public Mono<ResponseDTO<User>> update(@RequestBody User resource, ServerWebExchange exchange) {
return service.updateCurrentUser(resource, exchange)
Expand Down Expand Up @@ -125,11 +132,11 @@ public Mono<ResponseDTO<Boolean>> resetPasswordAfterForgotPassword(@RequestBody
.map(result -> new ResponseDTO<>(HttpStatus.OK.value(), result, null));
}

@Deprecated
@GetMapping("/me")
public Mono<ResponseDTO<User>> getUserProfile() {
public Mono<ResponseDTO<UserProfileDTO>> getUserProfile() {
return sessionUserService.getCurrentUser()
.map(user -> new ResponseDTO<>(HttpStatus.OK.value(), user, null));
.flatMap(service::buildUserProfileDTO)
.map(profile -> new ResponseDTO<>(HttpStatus.OK.value(), profile, null));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
package com.appsmith.server.dtos;

import com.appsmith.server.domains.Organization;
import com.appsmith.server.domains.User;
import lombok.Getter;
import lombok.Setter;
import lombok.Data;

import java.util.List;
import java.util.Set;

@Getter
@Setter
@Data
public class UserProfileDTO {

User user;
String email;

Organization currentOrganization;
Set<String> organizationIds;

String username;

String name;

String gender;

boolean isEmptyInstance = false;

List<ApplicationNameIdDTO> applications;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
import reactor.core.publisher.Mono;

public interface CustomUserRepository extends AppsmithRepository<User> {

Mono<User> findByEmail(String email, AclPermission aclPermission);

Mono<User> findByCaseInsensitiveEmail(String email);

Mono<Boolean> isUsersEmpty();

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import java.util.regex.Pattern;

import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.core.query.Query.query;

@Component
@Slf4j
Expand All @@ -38,4 +39,18 @@ public Mono<User> findByCaseInsensitiveEmail(String email) {
query.addCriteria(emailCriteria);
return mongoOperations.findOne(query, User.class);
}

/**
* Fetch minmal information from *a* user document in the database, and if found, return `true`, if empty, return `false`.
* @return Boolean, indicated where there exists at least one user in the system or not.
*/
@Override
public Mono<Boolean> isUsersEmpty() {
final Query q = query(new Criteria());
q.fields().include(fieldName(QUser.user.email));
return mongoOperations.findOne(q, User.class)
.map(ignored -> false)
.defaultIfEmpty(true);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.appsmith.server.domains.User;
import com.appsmith.server.dtos.InviteUsersDTO;
import com.appsmith.server.dtos.ResetUserPasswordDTO;
import com.appsmith.server.dtos.UserProfileDTO;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

Expand Down Expand Up @@ -34,4 +35,8 @@ public interface UserService extends CrudService<User, String> {
Mono<User> updateCurrentUser(User updates, ServerWebExchange exchange);

Map<String, String> getEmailParams(Organization organization, User inviterUser, String inviteUrl, boolean isNewUser);

Mono<Boolean> isUsersEmpty();

Mono<UserProfileDTO> buildUserProfileDTO(User user);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.appsmith.server.domains.UserRole;
import com.appsmith.server.dtos.InviteUsersDTO;
import com.appsmith.server.dtos.ResetUserPasswordDTO;
import com.appsmith.server.dtos.UserProfileDTO;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.helpers.PolicyUtils;
Expand Down Expand Up @@ -790,4 +791,26 @@ public Map<String, String> getEmailParams(Organization organization, User invite
return params;
}

@Override
public Mono<Boolean> isUsersEmpty() {
return repository.isUsersEmpty();
}

@Override
public Mono<UserProfileDTO> buildUserProfileDTO(User user) {
return isUsersEmpty()
.map(isUsersEmpty -> {
final UserProfileDTO profile = new UserProfileDTO();

profile.setEmail(user.getEmail());
profile.setOrganizationIds(user.getOrganizationIds());
profile.setUsername(user.getUsername());
profile.setName(user.getName());
profile.setGender(user.getGender());
profile.setEmptyInstance(isUsersEmpty);

return profile;
});
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.appsmith.server.solutions;

import com.appsmith.server.acl.AclPermission;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.helpers.PolicyUtils;
import com.appsmith.server.services.SessionUserService;
import com.appsmith.server.services.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
@Slf4j
public class EnvManager {

private final SessionUserService sessionUserService;
private final UserService userService;
private final PolicyUtils policyUtils;

@Value("${appsmith.admin.envfile:/opt/appsmith/docker.env}")
public String envFilePath;

private static final Pattern ENV_VARIABLE_PATTERN = Pattern.compile(
"^(?<name>[A-Z0-9_]+)\\s*=\\s*\"?(?<value>.*?)\"?$"
);

private static final Set<String> VARIABLE_BLACKLIST = Set.of(
"APPSMITH_ENCRYPTION_PASSWORD",
"APPSMITH_ENCRYPTION_SALT"
);

/**
* Updates values of variables in the envContent string, based on the changes map given. This function **only**
* updates values of variables that already defined in envContent. It NEVER adds new env variables to it. This is so
* a malicious request won't insert new dubious env variables.
* @param envContent String content of an env file.
* @param changes A map with variable name to new value.
* @return List of string lines for updated env file content.
*/
public static List<String> transformEnvContent(String envContent, Map<String, String> changes) {
for (final String variable : VARIABLE_BLACKLIST) {
if (changes.containsKey(variable)) {
throw new AppsmithException(AppsmithError.UNAUTHORIZED_ACCESS);
}
}

return envContent.lines()
.map(line -> {
final Matcher matcher = ENV_VARIABLE_PATTERN.matcher(line);
if (!matcher.matches()) {
return line;
}
final String name = matcher.group("name");
if (!changes.containsKey(name)) {
return line;
}
return line.substring(0, matcher.start("value"))
+ changes.get(name)
+ line.substring(matcher.end("value"));
})
.collect(Collectors.toList());
}

public Mono<Void> applyChanges(Map<String, String> changes) {
return sessionUserService.getCurrentUser()
.flatMap(user -> userService.findByEmail(user.getEmail()))
.filter(user -> policyUtils.isPermissionPresentForUser(
user.getPolicies(),
AclPermission.MANAGE_INSTANCE_ENV.getValue(),
user.getUsername()
))
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.UNAUTHORIZED_ACCESS)))
.flatMap(user -> {
final String originalContent;
try {
originalContent = Files.readString(Path.of(envFilePath));
} catch (IOException e) {
log.error("Unable to read env file " + envFilePath, e);
return Mono.error(e);
}

final List<String> changedContent = transformEnvContent(originalContent, changes);

try {
Files.write(Path.of(envFilePath), changedContent);
} catch (IOException e) {
log.error("Unable to write to env file " + envFilePath, e);
return Mono.error(e);
}

return Mono.empty();
});
}

}
Loading

0 comments on commit e20d616

Please sign in to comment.