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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ Passwords are hashed with Argon2 rather than stored as plain text. Secret values

The API decrypts a secret when returning it to its authenticated owner. This is server-side encryption, not end-to-end encryption.

Authentication and secret-management responses include `Cache-Control: no-store` so clients and intermediaries are instructed not to cache access tokens or decrypted secret values.

Successful secret creation, listing, retrieval, updates, and deletion are recorded in `audit_events` with the action, user ID, secret ID when applicable, and timestamp. Secret values are not included in these events. Authentication attempts are not currently audited.

The Docker Compose configuration contains credentials and an encryption key intended only for local development. Do not reuse these values in a deployed environment. Use unique secrets supplied through an appropriate secret manager or deployment configuration.
Expand Down
11 changes: 11 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)


@app.middleware("http")
async def prevent_sensitive_response_caching(request: Request, call_next):
response = await call_next(request)
path = request.url.path
if path == "/secrets" or path.startswith(("/auth/", "/secrets/")):
response.headers["Cache-Control"] = "no-store"
return response


bearer_scheme = HTTPBearer(auto_error=False)
Session = Annotated[AsyncSession, Depends(get_session)]
# Keep the password verification cost similar for unknown and existing accounts.
Expand Down
2 changes: 2 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ def test_login_rejects_invalid_password() -> None:
)

assert registered.status_code == 201
assert registered.headers["cache-control"] == "no-store"
assert invalid_login.status_code == 401
assert invalid_login.headers["cache-control"] == "no-store"
assert invalid_login.json()["detail"] == "Invalid credentials"


Expand Down
6 changes: 6 additions & 0 deletions tests/test_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,33 +39,39 @@ def test_secret_lifecycle_and_user_isolation() -> None:
json={"name": "database", "value": "initial-secret"},
)
assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
secret = created.json()
secret_id = secret["id"]
assert secret["name"] == "database"
assert secret["value"] == "initial-secret"

listed = client.get("/secrets", headers=owner_headers)
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert [item["id"] for item in listed.json()] == [secret_id]

other_access = client.get(f"/secrets/{secret_id}", headers=other_headers)
assert other_access.status_code == 404
assert other_access.headers["cache-control"] == "no-store"

updated = client.patch(
f"/secrets/{secret_id}",
headers=owner_headers,
json={"name": "database-prod", "value": "updated-secret"},
)
assert updated.status_code == 200
assert updated.headers["cache-control"] == "no-store"
assert updated.json()["name"] == "database-prod"
assert updated.json()["value"] == "updated-secret"

fetched = client.get(f"/secrets/{secret_id}", headers=owner_headers)
assert fetched.status_code == 200
assert fetched.headers["cache-control"] == "no-store"
assert fetched.json()["value"] == "updated-secret"

deleted = client.delete(f"/secrets/{secret_id}", headers=owner_headers)
assert deleted.status_code == 204
assert deleted.headers["cache-control"] == "no-store"

missing = client.get(f"/secrets/{secret_id}", headers=owner_headers)
assert missing.status_code == 404
Expand Down
Loading