Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,33 @@ Broadcasts a delete command to permanently remove all shards of a secret from th
**Responses:**
- `204 No Content`: The secret shards were successfully deleted from at least `m-k+1` nodes.
- `404 Not Found`: The secret does not exist.

---

### 6. Process a `.env` File
Processes a client-supplied `.env` file containing create, update, and delete operations.

**POST** `/api/v1/secrets/env`

Each non-empty line must use:

```env
Key1=new:val
Key2=update:val
Key3=delete:val
```

The action must be `new`, `update`, or `delete`. Keys must be unique within the file; duplicate keys fail the request before any write is attempted.

Supported request formats:

- `text/plain` body with `user` as a query parameter.
- `multipart/form-data` with `user` and a `file` part.
- `application/json` with `user` and `envFileContent` fields. The content field also accepts aliases `content`, `env`, or `envFile`.

**Responses:**
- `200 OK`: The file was processed. Returns operation counts.
- `400 Bad Request`: The file is malformed, has duplicate keys, or is missing a user.
- `404 Not Found`: An `update` or `delete` key does not exist.
- `409 Conflict`: A `new` key already exists.
- `503 Service Unavailable`: Failed to reach quorum during one of the write phases.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
import org.springframework.web.bind.annotation.RestController;

import edu.yu.capstone.DistributedSecretsVault.dto.secret.DeleteSecretRequest;
import edu.yu.capstone.DistributedSecretsVault.dto.secret.EnvFileRequest;
import edu.yu.capstone.DistributedSecretsVault.dto.secret.PostSecretRequest;
import edu.yu.capstone.DistributedSecretsVault.dto.secret.PutSecretRequest;
import edu.yu.capstone.DistributedSecretsVault.service.secret.DeleteSecretService;
import edu.yu.capstone.DistributedSecretsVault.service.secret.EnvFileService;
import edu.yu.capstone.DistributedSecretsVault.service.secret.GetSecretService;
import edu.yu.capstone.DistributedSecretsVault.service.secret.PostSecretService;
import edu.yu.capstone.DistributedSecretsVault.service.secret.PutSecretService;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -19,7 +22,10 @@
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;

@RestController
Expand All @@ -30,13 +36,16 @@ public class SecretController {
private final PostSecretService postSecretService;
private final PutSecretService putSecretService;
private final DeleteSecretService deleteSecretService;
private final EnvFileService envFileService;

public SecretController(GetSecretService getSecretService, PostSecretService postSecretService,
PutSecretService putSecretService, DeleteSecretService deleteSecretService) {
PutSecretService putSecretService, DeleteSecretService deleteSecretService,
EnvFileService envFileService) {
this.getSecretService = getSecretService;
this.postSecretService = postSecretService;
this.putSecretService = putSecretService;
this.deleteSecretService = deleteSecretService;
this.envFileService = envFileService;
}

@GetMapping("/{id}")
Expand All @@ -55,6 +64,29 @@ public ResponseEntity<String> postSecret(@RequestBody PostSecretRequest request)
return postSecretService.execute(request);
}

@PostMapping(value = "/env", consumes = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<String> postEnvFile(@RequestParam("user") String user, @RequestBody String envFileContent) {
return envFileService.execute(user, envFileContent);
}

@PostMapping(value = "/env", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<String> postEnvFile(@RequestParam("user") String user,
@RequestParam("file") MultipartFile file) {
try {
return envFileService.execute(user, new String(file.getBytes(), StandardCharsets.UTF_8));
} catch (IOException e) {
throw new IllegalArgumentException("Unable to read .env file", e);
}
}

@PostMapping(value = "/env", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> postEnvFile(@RequestBody EnvFileRequest request) {
if (request == null) {
throw new IllegalArgumentException("Request is required");
}
return envFileService.execute(request.getUser(), request.getEnvFileContent());
}

@PutMapping
public ResponseEntity<String> updateSecret(@RequestBody PutSecretRequest request) {
return putSecretService.execute(request);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package edu.yu.capstone.DistributedSecretsVault.dto.secret;

import com.fasterxml.jackson.annotation.JsonAlias;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class EnvFileRequest {
private String user;

@JsonAlias({ "content", "env", "envFile" })
private String envFileContent;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package edu.yu.capstone.DistributedSecretsVault.service.secret;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;

import edu.yu.capstone.DistributedSecretsVault.domain.model.SecretKey;
import edu.yu.capstone.DistributedSecretsVault.dto.secret.DeleteSecretRequest;
import edu.yu.capstone.DistributedSecretsVault.dto.secret.PostSecretRequest;
import edu.yu.capstone.DistributedSecretsVault.dto.secret.PutSecretRequest;
import edu.yu.capstone.DistributedSecretsVault.exceptions.DuplicateSecretException;
import edu.yu.capstone.DistributedSecretsVault.exceptions.SecretNotFoundException;
import edu.yu.capstone.DistributedSecretsVault.repository.SecretPartRepository;

@Service
public class EnvFileService {
private static final Logger log = LoggerFactory.getLogger(EnvFileService.class);

private final PostSecretService postSecretService;
private final PutSecretService putSecretService;
private final DeleteSecretService deleteSecretService;
private final SecretPartRepository secretPartRepository;

public EnvFileService(PostSecretService postSecretService,
PutSecretService putSecretService,
DeleteSecretService deleteSecretService,
SecretPartRepository secretPartRepository) {
this.postSecretService = postSecretService;
this.putSecretService = putSecretService;
this.deleteSecretService = deleteSecretService;
this.secretPartRepository = secretPartRepository;
}

public ResponseEntity<String> execute(String user, String envFileContent) {
validateUser(user);
List<EnvSecretOperation> operations = parseOperations(envFileContent);
validateOperationPreconditions(user, operations);

int created = 0;
int updated = 0;
int deleted = 0;
for (EnvSecretOperation operation : operations) {
switch (operation.action()) {
case NEW -> {
postSecretService.execute(new PostSecretRequest(operation.key(), operation.value(), user));
created++;
}
case UPDATE -> {
putSecretService.execute(new PutSecretRequest(operation.key(), operation.value(), user));
updated++;
}
case DELETE -> {
deleteSecretService.execute(new DeleteSecretRequest(operation.key(), user));
deleted++;
}
}
}

log.info("Processed .env file for user={}: created={}, updated={}, deleted={}",
user, created, updated, deleted);
return ResponseEntity.ok("Processed .env file: " + created + " created, "
+ updated + " updated, " + deleted + " deleted");
}

private void validateUser(String user) {
if (user == null || user.isBlank()) {
throw new IllegalArgumentException("User is required");
}
}

private List<EnvSecretOperation> parseOperations(String envFileContent) {
if (envFileContent == null || envFileContent.isBlank()) {
throw new IllegalArgumentException(".env file content is required");
}

List<EnvSecretOperation> operations = new ArrayList<>();
Set<String> seenKeys = new HashSet<>();
String[] lines = envFileContent.split("\\R", -1);
for (int index = 0; index < lines.length; index++) {
String rawLine = lines[index];
String trimmedLine = rawLine.trim();
if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
continue;
}

EnvSecretOperation operation = parseOperationLine(trimmedLine, index + 1);
if (!seenKeys.add(operation.key())) {
throw new IllegalArgumentException("Duplicate key in .env file: " + operation.key());
}
operations.add(operation);
}

if (operations.isEmpty()) {
throw new IllegalArgumentException(".env file must include at least one operation");
}
return operations;
}

private EnvSecretOperation parseOperationLine(String line, int lineNumber) {
int equalsIndex = line.indexOf('=');
if (equalsIndex <= 0) {
throw invalidLine(lineNumber);
}

String key = line.substring(0, equalsIndex).trim();
if (key.isBlank()) {
throw invalidLine(lineNumber);
}

String actionAndValue = line.substring(equalsIndex + 1);
int colonIndex = actionAndValue.indexOf(':');
if (colonIndex < 0) {
throw invalidLine(lineNumber);
}

String actionText = actionAndValue.substring(0, colonIndex).trim().toLowerCase(Locale.ROOT);
EnvAction action = switch (actionText) {
case "new" -> EnvAction.NEW;
case "update" -> EnvAction.UPDATE;
case "delete" -> EnvAction.DELETE;
default -> throw new IllegalArgumentException("Invalid .env action on line " + lineNumber
+ ": expected new, update, or delete");
};

String value = actionAndValue.substring(colonIndex + 1);
return new EnvSecretOperation(key, action, value);
}

private IllegalArgumentException invalidLine(int lineNumber) {
return new IllegalArgumentException("Invalid .env entry on line " + lineNumber
+ ": expected KEY=action:value");
}

private void validateOperationPreconditions(String user, List<EnvSecretOperation> operations) {
for (EnvSecretOperation operation : operations) {
SecretKey key = new SecretKey(user, operation.key());
boolean exists = secretPartRepository.exists(key);
switch (operation.action()) {
case NEW -> {
if (exists) {
throw new DuplicateSecretException("Secret already exists: " + operation.key());
}
}
case UPDATE, DELETE -> {
if (!exists) {
throw new SecretNotFoundException("Secret not found: " + operation.key());
}
}
}
}
}

private enum EnvAction {
NEW,
UPDATE,
DELETE
}

private record EnvSecretOperation(String key, EnvAction action, String value) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.nio.charset.StandardCharsets;
import java.util.Map;

import static org.mockito.ArgumentMatchers.any;
Expand Down Expand Up @@ -40,6 +42,9 @@ public class SecretControllerTest {
@MockitoBean
private DeleteSecretService deleteSecretService;

@MockitoBean
private EnvFileService envFileService;

@Test
public void testGetSecret() throws Exception {
when(getSecretService.getVersion(eq("user1"), eq("sec1"), eq(1L)))
Expand Down Expand Up @@ -87,6 +92,47 @@ public void testPostSecret() throws Exception {
.andExpect(content().string("created-id"));
}

@Test
public void testPostEnvFileAsPlainText() throws Exception {
String envFile = "Key1=new:val\nKey2=update:other\nKey3=delete:ignored\n";
when(envFileService.execute(eq("user1"), eq(envFile)))
.thenReturn(ResponseEntity.ok("processed"));

mockMvc.perform(post("/api/v1/secrets/env")
.param("user", "user1")
.contentType(MediaType.TEXT_PLAIN)
.content(envFile))
.andExpect(status().isOk())
.andExpect(content().string("processed"));
}

@Test
public void testPostEnvFileAsMultipart() throws Exception {
String envFile = "Key1=new:val\n";
MockMultipartFile file = new MockMultipartFile("file", "secrets.env",
MediaType.TEXT_PLAIN_VALUE, envFile.getBytes(StandardCharsets.UTF_8));
when(envFileService.execute(eq("user1"), eq(envFile)))
.thenReturn(ResponseEntity.ok("processed"));

mockMvc.perform(multipart("/api/v1/secrets/env")
.file(file)
.param("user", "user1"))
.andExpect(status().isOk())
.andExpect(content().string("processed"));
}

@Test
public void testPostEnvFileAsJson() throws Exception {
when(envFileService.execute(eq("user1"), eq("Key1=new:val")))
.thenReturn(ResponseEntity.ok("processed"));

mockMvc.perform(post("/api/v1/secrets/env")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"user\":\"user1\",\"content\":\"Key1=new:val\"}"))
.andExpect(status().isOk())
.andExpect(content().string("processed"));
}

@Test
public void testPutSecret() throws Exception {
when(putSecretService.execute(any(PutSecretRequest.class)))
Expand Down
Loading
Loading