Harden API-key model: per-key scoping + revocable keys, and configurable/whitelist-aware rate limiting (#16)

* API scope and rate limiting

* improvements from review
This commit is contained in:
Thijs Herman authored and GitHub committed 2026-05-28 11:45:16 +02:00
1 parent a400e07ae2
commit 495edffde3
11 files changed
+843 -42

No files matched your search

+69
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"strings"
"github.com/Turbootzz/vaultwarden-api/internal/auth"
"github.com/Turbootzz/vaultwarden-api/internal/validators"
"github.com/Turbootzz/vaultwarden-api/internal/vaultwarden"
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
@@ -91,6 +92,14 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error {
})
}
// Enforce the authenticated key's scope server-side, regardless of query filters.
if !h.applyKeyScope(c, &filter) {
logger.Warn.Printf("Request denied by key scope from IP: %s", c.IP())
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "secret not found",
})
}
value, err := h.vaultClient.GetSecret(secretName, filter)
if err != nil {
logger.Error.Printf("Failed to fetch secret (requested by IP: %s)", c.IP())
@@ -135,6 +144,66 @@ func resolveDim(dim, name, id string, nameMap map[string]string, out *string) er
return nil
}
// resolveRef resolves a scope reference that may be either a UUID or a friendly name.
// UUIDs pass through (existence not verified); names are looked up via NameMaps.
func resolveRef(nameMap map[string]string, ref string) (string, bool) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", false
}
if parsed, err := uuid.Parse(ref); err == nil {
return parsed.String(), true
}
return vaultwarden.LookupIDByName(nameMap, ref)
}
// resolveScopeRefs maps scope refs (UUIDs or names) to UUIDs, dropping any that
// don't resolve. The caller treats an all-unresolved dimension as deny (fail closed).
func resolveScopeRefs(refs []string, nameMap map[string]string) []string {
out := make([]string, 0, len(refs))
for _, ref := range refs {
if id, ok := resolveRef(nameMap, ref); ok {
out = append(out, id)
}
}
return out
}
// applyKeyScope sets the server-side scope fields on the filter from the
// authenticated key's scope. It returns false to deny the request (404) when a
// constrained dimension resolves to nothing — including unknown names and the
// pre-first-sync window — so a scoped key can never read outside its scope.
func (h *Handler) applyKeyScope(c *fiber.Ctx, filter *vaultwarden.SecretFilter) bool {
scope, ok := auth.ScopeFromCtx(c)
if !ok {
// No scope in context means the auth middleware did not run for this
// request. Fail closed rather than silently granting full access.
return false
}
if scope.IsEmpty() {
return true // unscoped key: full access
}
nm := h.vaultClient.NameMaps()
if len(scope.Organizations) > 0 {
ids := resolveScopeRefs(scope.Organizations, nm.Organizations)
if len(ids) == 0 {
return false
}
filter.OrganizationIDs = ids
}
if len(scope.Collections) > 0 {
ids := resolveScopeRefs(scope.Collections, nm.Collections)
if len(ids) == 0 {
return false
}
filter.CollectionIDs = ids
}
return true
}
// parseSecretFilters reads placement query params: at most one of id or name per dimension.
// Name-based filters are resolved against h.vaultClient.NameMaps(); unknown names fail.
// Id-based filters are accepted as-is after UUID parsing (existence is not checked here).
+106 -1
View File
@@ -5,9 +5,11 @@ import (
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/Turbootzz/vaultwarden-api/internal/auth"
"github.com/Turbootzz/vaultwarden-api/internal/vaultwarden"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
@@ -221,7 +223,7 @@ func TestParseSecretFilters(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("filter = %+v, want %+v", got, tt.want)
}
})
@@ -229,8 +231,11 @@ func TestParseSecretFilters(t *testing.T) {
}
func TestGetSecret(t *testing.T) {
const fullKey = "full-access-key-for-getsecret-test-000000"
h := NewHandler(vaultwarden.NewClient(nil, 0, 0, vaultwarden.WithState(testVaultItems(), testNameMaps())))
app := fiber.New()
// Mirror production: auth runs first and attaches a (here unscoped) scope.
app.Use(auth.Middleware(auth.NewStore([]auth.APIKey{{Name: "full", Key: fullKey}})))
app.Get("/secret/:name", h.GetSecret)
// Test the GetSecret handler with various input scenarios
@@ -315,6 +320,106 @@ func TestGetSecret(t *testing.T) {
url += "?" + tt.query
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+fullKey)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
body, _ := io.ReadAll(resp.Body)
if tt.wantStatus == http.StatusOK {
var payload map[string]string
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("json: %v", err)
}
if payload["value"] != tt.wantBody {
t.Errorf("value = %q, want %q", payload["value"], tt.wantBody)
}
return
}
if !strings.Contains(string(body), tt.wantBody) {
t.Errorf("body = %s, want substring %q", body, tt.wantBody)
}
})
}
}
// TestGetSecretFailsClosedWithoutAuth verifies that if the handler is reached
// without the auth middleware (no scope in context), it denies rather than
// granting full access.
func TestGetSecretFailsClosedWithoutAuth(t *testing.T) {
h := NewHandler(vaultwarden.NewClient(nil, 0, 0, vaultwarden.WithState(testVaultItems(), testNameMaps())))
app := fiber.New()
app.Get("/secret/:name", h.GetSecret) // intentionally no auth.Middleware
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/secret/db-password", nil)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want %d (fail closed)", resp.StatusCode, http.StatusNotFound)
}
}
func TestGetSecretScoped(t *testing.T) {
h := NewHandler(vaultwarden.NewClient(nil, 0, 0, vaultwarden.WithState(testVaultItems(), testNameMaps())))
// Keys wired through the real auth middleware so scope flows via c.Locals.
const (
fullKey = "full-access-0000000000000000000000000000"
colKey = "collection-scoped-11111111111111111111111"
orgKey = "org-scoped-2222222222222222222222222222222"
badScopeKey = "bad-scope-33333333333333333333333333333333"
)
store := auth.NewStore([]auth.APIKey{
{Name: "full", Key: fullKey},
{Name: "dev", Key: colKey, Scope: auth.Scope{Collections: []string{"Shared"}}},
{Name: "acme", Key: orgKey, Scope: auth.Scope{Organizations: []string{"Acme"}}},
{Name: "broken", Key: badScopeKey, Scope: auth.Scope{Collections: []string{"Nonexistent"}}},
})
app := fiber.New()
app.Use(auth.Middleware(store))
app.Get("/secret/:name", h.GetSecret)
tests := []struct {
name string
key string
path string
query string
wantStatus int
wantBody string
}{
// db-password (cipher-1) lives in org "Acme" / collection "Shared".
{"collection scope can read in-scope secret", colKey, "/secret/db-password", "", http.StatusOK, "s3cret"},
// other-password (cipher-2) has no collection -> out of a collection scope.
{"collection scope blocks out-of-scope secret", colKey, "/secret/other-password", "", http.StatusNotFound, "secret not found"},
{"org scope can read in-scope secret", orgKey, "/secret/db-password", "", http.StatusOK, "s3cret"},
// other-password is in a different org -> blocked server-side regardless of query.
{"org scope blocks other org secret", orgKey, "/secret/other-password", "", http.StatusNotFound, "secret not found"},
{"client filter cannot widen beyond org scope", orgKey, "/secret/other-password", "organization_id=" + testOtherOrgID, http.StatusNotFound, "secret not found"},
// Unscoped (full-access) key sees everything.
{"full access reads other org secret", fullKey, "/secret/other-password", "", http.StatusOK, "other-org"},
// Scope referencing an unknown collection name fails closed.
{"unresolvable scope fails closed", badScopeKey, "/secret/db-password", "", http.StatusNotFound, "secret not found"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := tt.path
if tt.query != "" {
url += "?" + tt.query
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+tt.key)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("app.Test: %v", err)