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:
make dev
To run the test suite:
make test
To run the API and PostgreSQL with Docker Compose:
make docker-up
Stop the Docker Compose stack with:
make docker-down
You can also run the Spring Boot app directly:
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, andNoteShareentities, with an addedtitlefield 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.
Security considerations
- Passwords are hashed with BCrypt before being persisted. Plaintext passwords are accepted only in registration/login request bodies and are never returned by the API.
- Authentication uses JWT bearer tokens. Protected endpoints require an
Authorization: Bearer <token>header. - The JWT signing secret is supplied through configuration. The local development value should not be used in production; production deployments should load it from an environment variable or secret manager.
- Access control is enforced in the service layer. Users can read and modify only their own notes unless a note has been explicitly shared with them.
- Shared notes are read-only for recipients. Only the owner can update, delete, or share a note.
- The API returns
404 Not Foundfor another user's unshared note so callers cannot distinguish between a missing note and a note they are not allowed to see. - Production deployments should run behind HTTPS, avoid logging credentials or tokens, and consider token expiration, rotation, revocation, rate limiting, and audit logging.
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:
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 Unauthorizedwhen no JWT is supplied. - Invalid UUID path variables return a structured
400 Bad Requestresponse.
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:
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:
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:
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:
curl -i http://localhost:8080/notes \
-H "Authorization: Bearer $ALICE_TOKEN"
Share Alice's note with Bob:
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:
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:
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.