package middleware import ( "crypto/subtle" "strings" "github.com/gofiber/fiber/v2" "github.com/gosec/gsc-ops-api/pkg/types" ) const APIKeyHeader = "X-API-Key" // principalKey is the Locals key under which the authenticated principal is // stored after a successful API-key check. const principalKey = "principal" // Principal is the authenticated caller behind an API key. type Principal struct { Name string Scopes []string } // GetPrincipal returns the authenticated principal, or nil. func GetPrincipal(c *fiber.Ctx) *Principal { if p, ok := c.Locals(principalKey).(*Principal); ok { return p } return nil } // APIKeyValidator reports whether a presented key is valid and, if so, the // name and scopes it was issued under. Used to back the static key list with a // dynamic, self-managed key store. type APIKeyValidator func(key string) (name string, scopes []string, ok bool) // APIKey validates the X-API-Key header against a fixed list of keys. func APIKey(validKeys []string) fiber.Handler { return APIKeyWithValidator(validKeys, nil) } // APIKeyWithValidator validates the X-API-Key header against a fixed list of // keys first (constant-time), then — if no static key matched — against an // optional dynamic validator (e.g. the DB-backed APIKeyService). Static keys // are trusted fully (wildcard scope); dynamic keys carry their own scopes, // enforced downstream by ScopeEnforce. func APIKeyWithValidator(validKeys []string, validate APIKeyValidator) fiber.Handler { return func(c *fiber.Ctx) error { key := c.Get(APIKeyHeader) if key == "" { apiErr := types.NewUnauthorized("Missing API key") return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) } for _, vk := range validKeys { if subtle.ConstantTimeCompare([]byte(key), []byte(vk)) == 1 { c.Locals(principalKey, &Principal{Name: "static", Scopes: []string{types.WildcardScope}}) return c.Next() } } if validate != nil { if name, scopes, ok := validate(key); ok { c.Locals(principalKey, &Principal{Name: name, Scopes: scopes}) return c.Next() } } apiErr := types.NewUnauthorized("Invalid API key") return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) } } // ScopeFor derives the scope a request requires from its method and path. // GET => read, any other method => write. The resource is the first path // segment after /api/v1; the API-key management routes map to apikeys:admin. // Returns "" when no scope can be derived (caller decides how to treat that). func ScopeFor(method, path string) string { p := strings.TrimPrefix(path, "/api/v1/") p = strings.TrimPrefix(p, "/") if p == "" { return "" } segs := strings.Split(p, "/") resource := segs[0] if resource == "admin" && len(segs) > 1 && segs[1] == "api-keys" { return types.ManageScope } action := "write" if method == fiber.MethodGet { action = "read" } return resource + ":" + action } // ScopeEnforce authorises the authenticated principal for the requested route. // Principals with the wildcard scope (the static Infisical keys) bypass; scoped // dynamic keys must hold the scope ScopeFor derives, else 403. func ScopeEnforce() fiber.Handler { return func(c *fiber.Ctx) error { p := GetPrincipal(c) if p == nil { apiErr := types.NewUnauthorized("Not authenticated") return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) } required := ScopeFor(c.Method(), c.Path()) if required == "" || types.ScopeSatisfied(p.Scopes, required) { return c.Next() } apiErr := types.NewForbidden("API key lacks the required scope: " + required) return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) } }