228 lines
12 KiB
Markdown
228 lines
12 KiB
Markdown
# Secure Notes Vault API
|
|
|
|
## System overview
|
|
|
|
Secure Notes Vault API is a Spring Boot REST service for creating, managing, and sharing private notes. Users register and log in with username/password credentials, then authenticate subsequent requests with a JWT bearer token.
|
|
|
|
The application is organized around a standard controller-service-repository flow:
|
|
|
|
- Controllers expose the authentication and notes REST endpoints.
|
|
- Spring Security and a JWT filter authenticate protected requests.
|
|
- Services enforce note ownership and read-only shared-note access rules.
|
|
- JPA repositories persist users, notes, and note shares.
|
|
- Flyway migrations manage the database schema for both local H2 and PostgreSQL-backed runs.
|
|
|
|
## Running the program
|
|
|
|
This project requires Java 21 and Maven. The API starts on `http://localhost:8080`.
|
|
|
|
For the fastest local setup, run the app with the in-memory H2 database:
|
|
|
|
```bash
|
|
make dev
|
|
```
|
|
|
|
To run the test suite:
|
|
|
|
```bash
|
|
make test
|
|
```
|
|
|
|
To run the API and PostgreSQL with Docker Compose:
|
|
|
|
```bash
|
|
make docker-up
|
|
```
|
|
|
|
Stop the Docker Compose stack with:
|
|
|
|
```bash
|
|
make docker-down
|
|
```
|
|
|
|
You can also run the Spring Boot app directly:
|
|
|
|
```bash
|
|
JWT_SECRET=dev-secret-change-me-do-not-use-in-production mvn spring-boot:run
|
|
```
|
|
|
|
## Design decisions
|
|
|
|
- Java 21 was selected because the challenge is for a Java-oriented backend role and because modern Java records work well for request/response DTOs.
|
|
- Spring Boot provides a compact way to build a production-style REST API with validation, dependency injection, security, persistence, and test support without adding much custom framework code.
|
|
- Maven is used for dependency management and repeatable local builds because it is widely understood in Java backend environments.
|
|
- Spring Security handles authentication boundaries, while a custom JWT filter keeps the API stateless after login. This makes the service easier to run behind a load balancer because requests do not depend on server-side sessions.
|
|
- Passwords are stored as BCrypt hashes, never as plaintext. The JWT signing secret is supplied through configuration and should come from a secret manager or environment variable outside local development.
|
|
- H2 is used for fast local development and automated tests. PostgreSQL is supported through Docker Compose and a Spring profile because it is a better fit for a production relational datastore.
|
|
- JPA/Hibernate keeps the persistence layer concise for this size of application. The schema is still managed explicitly with Flyway so database changes are versioned and repeatable.
|
|
- The data model follows the required `User`, `Note`, and `NoteShare` entities, with an added `title` field on notes for a more realistic note-taking API.
|
|
- Share records grant read-only access to a specific user. Owners can create, update, delete, and share their notes; shared recipients can only read them.
|
|
- UUID primary keys are used so identifiers are opaque and do not expose record counts or ordering.
|
|
- The service returns structured error responses for application-level failures so clients can handle validation, authentication, authorization, and conflict cases predictably.
|
|
- This implementation favors clarity over extra infrastructure. Features such as refresh tokens, token revocation, audit logging, pagination, rate limiting, and OpenAPI generation would be good production follow-ups.
|
|
|
|
## Testing
|
|
|
|
The automated tests run with Spring Boot, MockMvc, Spring Security, JPA, validation, and Flyway-backed H2 enabled. This keeps the tests close to real API behavior while still making them fast enough to run locally.
|
|
|
|
Run the test suite with:
|
|
|
|
```bash
|
|
make test
|
|
```
|
|
|
|
Current automated coverage includes:
|
|
|
|
- Registration creates users, hashes passwords, rejects duplicate usernames, and returns validation errors for invalid payloads.
|
|
- Note creation requires a bearer token, persists notes for the authenticated owner, and rejects invalid note payloads.
|
|
- Note listing returns only notes owned by the caller plus notes explicitly shared with the caller.
|
|
- Note retrieval returns owned notes, returns shared notes as `SHARED_READ`, and verifies that a user cannot access another user's unshared note.
|
|
- Protected endpoints return `401 Unauthorized` when no JWT is supplied.
|
|
- Invalid UUID path variables return a structured `400 Bad Request` response.
|
|
|
|
The required access-control test is covered by `NoteRetrievalControllerTest.getNoteReturnsNotFoundForUnsharedNoteOwnedByAnotherUser`, which creates a note as one user, authenticates as a different user, and asserts that the second user receives `404 Not Found` for the unshared note.
|
|
|
|
Given time, more automated tests would be done and a full coverage analysis would be in order.
|
|
|
|
A React app located at https://git.seanstarkey.dev/starkey/notes was also used for manual API testing. It exercises the API from a client perspective and displays REST requests/responses for debugging. It runs in a Docker container. This app was worth the investment because of the problems I had with some REST communications.
|
|
|
|
## API Endpoints
|
|
|
|
### Authentication
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `POST` | `/auth/register` | Register a new user |
|
|
| `POST` | `/auth/login` | Authenticate and receive a JWT |
|
|
|
|
### Notes
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| `POST` | `/notes` | Create a new note |
|
|
| `GET` | `/notes` | List owned notes and notes shared with you |
|
|
| `GET` | `/notes/{id}` | Get a note by ID (owner or shared recipient) |
|
|
| `PUT` | `/notes/{id}` | Update a note (owner only) |
|
|
| `DELETE` | `/notes/{id}` | Delete a note (owner only) |
|
|
| `POST` | `/notes/{id}/share` | Share a note with another user (read-only) |
|
|
|
|
## API Usage Examples
|
|
|
|
The following examples assume the API is running at `http://localhost:8080`.
|
|
|
|
Register two users:
|
|
|
|
```bash
|
|
curl -i -X POST http://localhost:8080/auth/register \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"username":"alice","password":"password123"}'
|
|
|
|
curl -i -X POST http://localhost:8080/auth/register \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"username":"bob","password":"password123"}'
|
|
```
|
|
|
|
Log in and save each JWT:
|
|
|
|
```bash
|
|
ALICE_TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"username":"alice","password":"password123"}' | jq -r '.token')
|
|
|
|
BOB_TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"username":"bob","password":"password123"}' | jq -r '.token')
|
|
```
|
|
|
|
Create a note as Alice:
|
|
|
|
```bash
|
|
NOTE_ID=$(curl -s -X POST http://localhost:8080/notes \
|
|
-H "Authorization: Bearer $ALICE_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"title":"Launch checklist","content":"Rotate credentials before launch."}' | jq -r '.id')
|
|
```
|
|
|
|
List Alice's visible notes:
|
|
|
|
```bash
|
|
curl -i http://localhost:8080/notes \
|
|
-H "Authorization: Bearer $ALICE_TOKEN"
|
|
```
|
|
|
|
Share Alice's note with Bob:
|
|
|
|
```bash
|
|
curl -i -X POST http://localhost:8080/notes/$NOTE_ID/share \
|
|
-H "Authorization: Bearer $ALICE_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"username":"bob"}'
|
|
```
|
|
|
|
Bob can read the shared note:
|
|
|
|
```bash
|
|
curl -i http://localhost:8080/notes/$NOTE_ID \
|
|
-H "Authorization: Bearer $BOB_TOKEN"
|
|
```
|
|
|
|
Bob cannot update the shared note because shared access is read-only:
|
|
|
|
```bash
|
|
curl -i -X PUT http://localhost:8080/notes/$NOTE_ID \
|
|
-H "Authorization: Bearer $BOB_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"title":"Edited by Bob","content":"This update should be rejected."}'
|
|
```
|
|
|
|
## HTTP return codes
|
|
|
|
Application-defined HTTP status codes are generated in `AuthController` for successful registration and in `GlobalExceptionHandler` for API error responses. Spring Security and Spring MVC may still produce framework-level responses for requests that do not reach a controller, such as unauthenticated protected requests or unsupported methods.
|
|
|
|
| HTTP code | Status | Generated in | Trigger | Response body |
|
|
|---:|---|---|---|---|
|
|
| `201` | Created | `AuthController.register` | `POST /auth/register` creates a new user. | `UserResponse` with `id`, `username`, and `createdAt`. |
|
|
| `400` | Bad Request | `GlobalExceptionHandler.handleMalformedJson` | Request body cannot be parsed or converted from JSON. | `ErrorResponse` with `Malformed request body`. |
|
|
| `400` | Bad Request | `GlobalExceptionHandler.handleTypeMismatch` | Path variable or query parameter cannot be converted to the expected type. | `ErrorResponse` with `Invalid request parameter`. |
|
|
| `401` | Unauthorized | `GlobalExceptionHandler.handleAuthentication`; Spring Security filter chain | Authentication fails or an unauthenticated request targets a protected route. | `ErrorResponse` with `Invalid username or password` when handled by `GlobalExceptionHandler`; framework-generated body when rejected before controller handling. |
|
|
| `403` | Forbidden | `GlobalExceptionHandler.handleForbidden` | Authenticated caller is not allowed to perform the requested operation. | `ErrorResponse` with the exception message. |
|
|
| `404` | Not Found | `GlobalExceptionHandler.handleNotFound` | Requested domain resource does not exist or should be hidden from the caller. | `ErrorResponse` with the exception message. |
|
|
| `404` | Not Found | `GlobalExceptionHandler.handleNoResourceFound` | No controller route or static resource matches the request. | `ErrorResponse` with `Not found`. |
|
|
| `409` | Conflict | `GlobalExceptionHandler.handleConflict`; `AuthController.register` throws `ConflictException` | Duplicate username or other persistence/domain uniqueness conflict. | `ErrorResponse` with the exception message. |
|
|
| `422` | Unprocessable Entity | `GlobalExceptionHandler.handleValidation` | Bean Validation fails for a request body annotated with `@Valid`. | `ValidationErrorResponse` with `error` and per-field `fields`. |
|
|
|
|
## Database
|
|
|
|
- The schema is the basic requirements for a User, Note and NoteShare.
|
|
- H2 database is used for development.
|
|
- Flyway will allow us to migrate to Postgres for "production".
|
|
|
|
| Table | Column | Type | Required | Key / Constraint | Description |
|
|
|---|---|---:|:---:|---|---|
|
|
| `users` | `id` | `UUID` | Yes | Primary key | Unique user identifier. |
|
|
| `users` | `username` | `VARCHAR(100)` | Yes | Unique: `uq_users_username` | User login handle. |
|
|
| `users` | `password_hash` | `VARCHAR(255)` | Yes | | BCrypt password hash. |
|
|
| `users` | `created_at` | `TIMESTAMP WITH TIME ZONE` | Yes | | Account creation timestamp. |
|
|
| |
|
|
| `notes` | `id` | `UUID` | Yes | Primary key | Unique note identifier. |
|
|
| `notes` | `owner_id` | `UUID` | Yes | FK to `users(id)`; indexed by `idx_notes_owner_id` | User who owns the note. |
|
|
| `notes` | `title` | `VARCHAR(255)` | Yes | | Note title. |
|
|
| `notes` | `content` | `TEXT` | Yes | | Note body content. |
|
|
| `notes` | `created_at` | `TIMESTAMP WITH TIME ZONE` | Yes | | Note creation timestamp. |
|
|
| `notes` | `updated_at` | `TIMESTAMP WITH TIME ZONE` | Yes | | Last note update timestamp. |
|
|
| |
|
|
| `note_shares` | `id` | `UUID` | Yes | Primary key | Unique share identifier. |
|
|
| `note_shares` | `note_id` | `UUID` | Yes | FK to `notes(id)` with `ON DELETE CASCADE`; indexed by `idx_note_shares_note_id` | Note being shared. |
|
|
| `note_shares` | `shared_with_user_id` | `UUID` | Yes | FK to `users(id)`; indexed by `idx_note_shares_shared_with_user_id` | User receiving read-only access. |
|
|
| `note_shares` | `created_at` | `TIMESTAMP WITH TIME ZONE` | Yes | | Share creation timestamp. |
|
|
| `note_shares` | `note_id`, `shared_with_user_id` | `UUID`, `UUID` | Yes | Unique: `uq_note_shares_note_user` | Prevents duplicate shares for the same note and user. |
|
|
|
|
|
|
## References
|
|
|
|
A list of projects looked at for reference.
|
|
- https://github.com/spring-petclinic/spring-petclinic-rest
|
|
- https://github.com/spring-projects/spring-petclinic
|
|
- https://github.com/gothinkster/spring-boot-realworld-example-app
|
|
- https://github.com/spring-projects/spring-data-examples
|