Compare commits

...

4 Commits

Author SHA1 Message Date
vikrantgupta25
5c6fe9bed8 chore(openapi): regenerate spec 2026-11-03 21:09:55 +05:30
vikrantgupta25
dd0a9ebfc3 fix(authn): keep route-declared error codes in the spec 2026-11-03 21:09:54 +05:30
vikrantgupta25
9fefa7266e chore(openapi): regenerate spec 2026-11-03 20:01:56 +05:30
vikrantgupta25
7a632b64f7 feat(authn): add authn middleware and remove OpenAccess
AuthN runs right after IdentN and rejects every handler.New route without
claims unless it declares AllowAnonymous. Legacy raw routes keep their own
gates until they are migrated.
2026-11-03 20:01:54 +05:30
25 changed files with 327 additions and 83 deletions

View File

@@ -13789,6 +13789,12 @@ paths:
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"500":
content:
application/json:
@@ -17951,6 +17957,18 @@ paths:
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
@@ -17963,6 +17981,8 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key: []
summary: Gets my service account
tags:
- serviceaccount
@@ -17979,6 +17999,18 @@ paths:
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
@@ -17991,6 +18023,8 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key: []
summary: Updates my service account
tags:
- serviceaccount
@@ -24694,12 +24728,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
@@ -24781,12 +24809,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
@@ -30291,6 +30313,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"500":
content:
application/json:

View File

@@ -142,7 +142,7 @@ The pieces:
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). When a side's ids come from a list extractor (`BodyJSONArray` or a custom `ResourceIDsExtractor`) and that list resolves to nothing at request time, there is nothing to link: the def resolves to no resources, so no check runs and no audit event is emitted (e.g. inviting a user with an empty `userRoles`). A single-id side (`OneID`) always resolves to exactly one id and fails closed when it is empty. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `CheckWithoutClaims` serves anonymous routes such as public dashboards.
### 5. Backfill existing organizations (only if needed)

View File

@@ -21,6 +21,8 @@ Each route wraps a module handler method with the following:
- A generic HTTP `handler.Handler` (from `pkg/http/handler`)
- An `OpenAPIDef` that describes the operation for OpenAPI generation
Authentication is enforced for every route registered through `handler.New` by the router-level `AuthN` middleware; a route only needs to say something about it when it must be reachable without credentials, by passing `handler.AllowAnonymous()` to `handler.New`.
For example, in `pkg/apiserver/signozapiserver`:
```go
@@ -183,7 +185,7 @@ The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAP
The generic handler:
- Automatically appends `401`, `403`, and `500` to `ErrorStatusCodes` when appropriate.
- Automatically appends `500` to `ErrorStatusCodes`, `401` unless the route is public, and `403` when `SecuritySchemes` is set.
- Registers request and response schemas with the OpenAPI reflector so they appear in `docs/api/openapi.yml`.
See existing examples in:

View File

@@ -65,10 +65,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// note: add ee override methods first
// routes available only in ee version
router.HandleFunc("/api/v1/features", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/features", ah.getFeatureFlags).Methods(http.MethodGet)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/version", ah.getVersion).Methods(http.MethodGet)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

View File

@@ -208,7 +208,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services", handler.New(
provider.authzMiddleware.OpenAccess(provider.cloudIntegrationHandler.ListServicesMetadata),
provider.cloudIntegrationHandler.ListServicesMetadata,
handler.OpenAPIDef{
ID: "ListServicesMetadata",
Tags: []string{"cloudintegration"},
@@ -255,7 +255,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
provider.authzMiddleware.OpenAccess(provider.cloudIntegrationHandler.GetService),
provider.cloudIntegrationHandler.GetService,
handler.OpenAPIDef{
ID: "GetService",
Tags: []string{"cloudintegration"},

View File

@@ -657,7 +657,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -686,7 +686,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -9,7 +9,7 @@ import (
)
func (provider *provider) addFlaggerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.OpenAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{
if err := router.Handle("/api/v2/features", handler.New(provider.flaggerHandler.GetFeatures, handler.OpenAPIDef{
ID: "GetFeatures",
Tags: []string{"features"},
Summary: "Get features",

View File

@@ -9,7 +9,7 @@ import (
)
func (provider *provider) addGlobalRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/global/config", handler.New(provider.authzMiddleware.OpenAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{
if err := router.Handle("/api/v1/global/config", handler.New(provider.globalHandler.GetConfig, handler.OpenAPIDef{
ID: "GetGlobalConfig",
Tags: []string{"global"},
Summary: "Get global config",
@@ -22,7 +22,7 @@ func (provider *provider) addGlobalRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: nil,
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -117,7 +117,7 @@ func (provider *provider) addLicensingRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v4/licenses/active", handler.New(provider.authzMiddleware.OpenAccess(provider.licensingHandler.GetActive), handler.OpenAPIDef{
if err := router.Handle("/api/v4/licenses/active", handler.New(provider.licensingHandler.GetActive, handler.OpenAPIDef{
ID: "GetActiveLicense",
Tags: []string{"licenses"},
Summary: "Get the active license.",

View File

@@ -70,6 +70,10 @@ func (h *prometheusOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext)
}
}
func (h *prometheusOpenAPIHandler) AllowsAnonymous() bool {
return false
}
func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
return []handler.ResourceDef{handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,

View File

@@ -324,6 +324,7 @@ func newProvider(
router.Use(middleware.NewRecovery(settings.Logger()).Wrap)
router.Use(middleware.NewOtel("apiserver", providerSettings.MeterProvider, providerSettings.TracerProvider).Wrap)
router.Use(middleware.NewIdentN(identNResolver, sharder, settings.Logger()).Wrap)
router.Use(middleware.NewAuthN(settings.Logger()).Wrap)
router.Use(middleware.NewTimeout(settings.Logger(),
config.Timeout.ExcludedRoutes,
config.Timeout.Default,

View File

@@ -55,9 +55,13 @@ func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
return nil
}
func (handler *healthOpenAPIHandler) AllowsAnonymous() bool {
return true
}
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
provider.authzMiddleware.OpenAccess(provider.factoryHandler.Healthz),
provider.factoryHandler.Healthz,
"Healthz",
"Health check",
)).Methods(http.MethodGet).GetError(); err != nil {
@@ -65,14 +69,14 @@ func (provider *provider) addRegistryRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v2/readyz", newHealthOpenAPIHandler(
provider.authzMiddleware.OpenAccess(provider.factoryHandler.Readyz),
provider.factoryHandler.Readyz,
"Readyz",
"Readiness check",
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/livez", pkghandler.New(provider.authzMiddleware.OpenAccess(provider.factoryHandler.Livez),
if err := router.Handle("/api/v2/livez", pkghandler.New(provider.factoryHandler.Livez,
pkghandler.OpenAPIDef{
ID: "Livez",
Tags: []string{"health"},
@@ -81,6 +85,7 @@ func (provider *provider) addRegistryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
},
pkghandler.AllowAnonymous(),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -67,7 +67,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authzMiddleware.OpenAccess(provider.serviceAccountHandler.GetMe), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.serviceAccountHandler.GetMe, handler.OpenAPIDef{
ID: "GetMyServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets my service account",
@@ -79,7 +79,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: nil,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderAPIKey.StringValue()}},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -138,7 +138,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authzMiddleware.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.serviceAccountHandler.UpdateMe, handler.OpenAPIDef{
ID: "UpdateMyServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates my service account",
@@ -150,7 +150,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: nil,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderAPIKey.StringValue()}},
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}

View File

@@ -9,7 +9,7 @@ import (
)
func (provider *provider) addSessionRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/sessions/email_password", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.CreateSessionByEmailPassword), handler.OpenAPIDef{
if err := router.Handle("/api/v2/sessions/email_password", handler.New(provider.sessionHandler.CreateSessionByEmailPassword, handler.OpenAPIDef{
ID: "CreateSessionByEmailPassword",
Tags: []string{"sessions"},
Summary: "Create session by email and password",
@@ -22,11 +22,11 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/sessions/context", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.GetSessionContext), handler.OpenAPIDef{
if err := router.Handle("/api/v2/sessions/context", handler.New(provider.sessionHandler.GetSessionContext, handler.OpenAPIDef{
ID: "GetSessionContext",
Tags: []string{"sessions"},
Summary: "Get session context",
@@ -39,11 +39,11 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/sessions/rotate", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.RotateSession), handler.OpenAPIDef{
if err := router.Handle("/api/v2/sessions/rotate", handler.New(provider.sessionHandler.RotateSession, handler.OpenAPIDef{
ID: "RotateSession",
Tags: []string{"sessions"},
Summary: "Rotate session",
@@ -53,14 +53,14 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
Response: new(authtypes.GettableToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/sessions", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.DeleteSession), handler.OpenAPIDef{
if err := router.Handle("/api/v2/sessions", handler.New(provider.sessionHandler.DeleteSession, handler.OpenAPIDef{
ID: "DeleteSession",
Tags: []string{"sessions"},
Summary: "Delete session",
@@ -70,14 +70,14 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodDelete).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/complete/google", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.CreateSessionByGoogleCallback), handler.OpenAPIDef{
if err := router.Handle("/api/v1/complete/google", handler.New(provider.sessionHandler.CreateSessionByGoogleCallback, handler.OpenAPIDef{
ID: "CreateSessionByGoogleCallback",
Tags: []string{"sessions"},
Summary: "Create session by google callback",
@@ -90,11 +90,11 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/complete/saml", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.CreateSessionBySAMLCallback), handler.OpenAPIDef{
if err := router.Handle("/api/v1/complete/saml", handler.New(provider.sessionHandler.CreateSessionBySAMLCallback, handler.OpenAPIDef{
ID: "CreateSessionBySAMLCallback",
Tags: []string{"sessions"},
Summary: "Create session by saml callback",
@@ -110,11 +110,11 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/complete/oidc", handler.New(provider.authzMiddleware.OpenAccess(provider.sessionHandler.CreateSessionByOIDCCallback), handler.OpenAPIDef{
if err := router.Handle("/api/v1/complete/oidc", handler.New(provider.sessionHandler.CreateSessionByOIDCCallback, handler.OpenAPIDef{
ID: "CreateSessionByOIDCCallback",
Tags: []string{"sessions"},
Summary: "Create session by oidc callback",
@@ -127,7 +127,7 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -9,7 +9,7 @@ import (
func (provider *provider) addStatsReporterRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/stats", handler.New(
provider.authzMiddleware.OpenAccess(provider.statsHandler.Get),
provider.statsHandler.Get,
handler.OpenAPIDef{
ID: "GetStats",
Tags: []string{"stats"},

View File

@@ -38,7 +38,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/user/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.GetMyUserDeprecated), handler.OpenAPIDef{
if err := router.Handle("/api/v1/user/me", handler.New(provider.userHandler.GetMyUserDeprecated, handler.OpenAPIDef{
ID: "GetMyUserDeprecated",
Tags: []string{"users"},
Summary: "Get my user",
@@ -55,7 +55,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.GetMyUser), handler.OpenAPIDef{
if err := router.Handle("/api/v2/users/me", handler.New(provider.userHandler.GetMyUser, handler.OpenAPIDef{
ID: "GetMyUser",
Tags: []string{"users"},
Summary: "Get my user v2",
@@ -115,7 +115,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.UpdateMyUser), handler.OpenAPIDef{
if err := router.Handle("/api/v2/users/me", handler.New(provider.userHandler.UpdateMyUser, handler.OpenAPIDef{
ID: "UpdateMyUserV2",
Tags: []string{"users"},
Summary: "Update my user v2",
@@ -281,7 +281,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/reset_password_tokens/verify", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.VerifyResetPasswordToken), handler.OpenAPIDef{
if err := router.Handle("/api/v2/reset_password_tokens/verify", handler.New(provider.userHandler.VerifyResetPasswordToken, handler.OpenAPIDef{
ID: "VerifyResetPasswordToken",
Tags: []string{"users"},
Summary: "Verify a reset password token",
@@ -294,11 +294,11 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/me/factor_password", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ChangePassword), handler.OpenAPIDef{
if err := router.Handle("/api/v2/users/me/factor_password", handler.New(provider.userHandler.ChangePassword, handler.OpenAPIDef{
ID: "UpdateMyPassword",
Tags: []string{"users"},
Summary: "Updates my password",
@@ -315,7 +315,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/factor_password/forgot", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ForgotPassword), handler.OpenAPIDef{
if err := router.Handle("/api/v2/factor_password/forgot", handler.New(provider.userHandler.ForgotPassword, handler.OpenAPIDef{
ID: "ForgotPassword",
Tags: []string{"users"},
Summary: "Forgot password",
@@ -328,11 +328,11 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnprocessableEntity},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/factor_password/reset", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ResetPassword), handler.OpenAPIDef{
if err := router.Handle("/api/v2/factor_password/reset", handler.New(provider.userHandler.ResetPassword, handler.OpenAPIDef{
ID: "ResetPassword",
Tags: []string{"users"},
Summary: "Reset password",
@@ -345,7 +345,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.AllowAnonymous())).Methods(http.MethodPost).GetError(); err != nil {
return err
}

View File

@@ -16,28 +16,17 @@ type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
ResourceDefs() []ResourceDef
AllowsAnonymous() bool
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
resourceDefs []ResourceDef
anonymous bool
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
// Remove duplicate error status codes
openAPIDef.ErrorStatusCodes = slices.DeleteFunc(openAPIDef.ErrorStatusCodes, func(statusCode int) bool {
return statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || statusCode == http.StatusInternalServerError
})
// Add internal server error
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusInternalServerError)
// Add unauthorized and forbidden status codes
if len(openAPIDef.SecuritySchemes) > 0 {
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
}
handler := &handler{
handlerFunc: handlerFunc,
openAPIDef: openAPIDef,
@@ -47,6 +36,22 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Ha
opt(handler)
}
// Add internal server error, unauthorized and forbidden status codes
statusCodes := []int{http.StatusInternalServerError}
if !handler.anonymous {
statusCodes = append(statusCodes, http.StatusUnauthorized)
}
if len(handler.openAPIDef.SecuritySchemes) > 0 {
statusCodes = append(statusCodes, http.StatusForbidden)
}
for _, statusCode := range statusCodes {
if !slices.Contains(handler.openAPIDef.ErrorStatusCodes, statusCode) {
handler.openAPIDef.ErrorStatusCodes = append(handler.openAPIDef.ErrorStatusCodes, statusCode)
}
}
return handler
}
@@ -135,3 +140,7 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
func (handler *handler) ResourceDefs() []ResourceDef {
return handler.resourceDefs
}
func (handler *handler) AllowsAnonymous() bool {
return handler.anonymous
}

View File

@@ -22,6 +22,8 @@ func (bespokeOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
func (bespokeOpenAPIHandler) ResourceDefs() []ResourceDef { return nil }
func (bespokeOpenAPIHandler) AllowsAnonymous() bool { return false }
func TestAttachStabilities(t *testing.T) {
router := mux.NewRouter()
router.Handle("/development", New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: "Development", SuccessStatusCode: http.StatusOK, Stability: StabilityDevelopment})).Methods(http.MethodGet)
@@ -73,3 +75,26 @@ func TestAttachStabilities(t *testing.T) {
})
}
}
func TestNewErrorStatusCodes(t *testing.T) {
testCases := []struct {
name string
def OpenAPIDef
opts []Option
expected []int
}{
{name: "Protected_NoSecuritySchemes_AddsUnauthorized", def: OpenAPIDef{}, expected: []int{http.StatusInternalServerError, http.StatusUnauthorized}},
{name: "Protected_SecuritySchemes_AddsUnauthorizedAndForbidden", def: OpenAPIDef{SecuritySchemes: []OpenAPISecurityScheme{{Name: "tokenizer"}}}, expected: []int{http.StatusInternalServerError, http.StatusUnauthorized, http.StatusForbidden}},
{name: "Protected_DeclaredCodes_NoDuplicates", def: OpenAPIDef{ErrorStatusCodes: []int{http.StatusUnauthorized, http.StatusInternalServerError}}, expected: []int{http.StatusUnauthorized, http.StatusInternalServerError}},
{name: "Anonymous_NoUnauthorized", def: OpenAPIDef{ErrorStatusCodes: []int{http.StatusBadRequest}}, opts: []Option{AllowAnonymous()}, expected: []int{http.StatusBadRequest, http.StatusInternalServerError}},
{name: "Anonymous_DeclaredUnauthorized_Kept", def: OpenAPIDef{ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized}}, opts: []Option{AllowAnonymous()}, expected: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
built := New(func(http.ResponseWriter, *http.Request) {}, testCase.def, testCase.opts...).(*handler)
assert.Equal(t, testCase.expected, built.openAPIDef.ErrorStatusCodes)
assert.Equal(t, len(testCase.opts) > 0, built.AllowsAnonymous())
})
}
}

View File

@@ -7,3 +7,9 @@ func WithResourceDefs(defs ...ResourceDef) Option {
h.resourceDefs = append(h.resourceDefs, defs...)
}
}
func AllowAnonymous() Option {
return func(h *handler) {
h.anonymous = true
}
}

View File

@@ -0,0 +1,59 @@
package middleware
import (
"log/slog"
"net/http"
"github.com/gorilla/mux"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
const (
authnDeniedMessage string = "::AUTHN-DENIED::"
)
type AuthN struct {
logger *slog.Logger
}
func NewAuthN(logger *slog.Logger) *AuthN {
if logger == nil {
panic("cannot build authn middleware, logger is empty")
}
return &AuthN{logger: logger.With(slog.String("pkg", pkgname))}
}
func (middleware *AuthN) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if allowsAnonymous(req) {
next.ServeHTTP(rw, req)
return
}
if _, err := authtypes.ClaimsFromContext(req.Context()); err != nil {
middleware.logger.WarnContext(req.Context(), authnDeniedMessage, slog.String("path", req.URL.Path), slog.String("method", req.Method))
render.Error(rw, err)
return
}
next.ServeHTTP(rw, req)
})
}
func allowsAnonymous(req *http.Request) bool {
route := mux.CurrentRoute(req)
if route == nil {
return false
}
registered, ok := route.GetHandler().(handler.Handler)
if !ok {
return true
}
return registered.AllowsAnonymous()
}

View File

@@ -0,0 +1,70 @@
package middleware
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func TestAuthN(t *testing.T) {
t.Parallel()
ok := func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusNoContent) }
newRouter := func(withClaims bool) *mux.Router {
router := mux.NewRouter()
if withClaims {
router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims := authtypes.Claims{OrgID: valuer.GenerateUUID().StringValue(), UserID: valuer.GenerateUUID().StringValue()}
next.ServeHTTP(rw, req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims)))
})
})
}
router.Use(NewAuthN(slog.New(slog.DiscardHandler)).Wrap)
router.Handle("/anonymous", handler.New(ok, handler.OpenAPIDef{ID: "Anonymous", SuccessStatusCode: http.StatusNoContent}, handler.AllowAnonymous())).Methods(http.MethodGet)
router.Handle("/protected", handler.New(ok, handler.OpenAPIDef{ID: "Protected", SuccessStatusCode: http.StatusNoContent})).Methods(http.MethodGet)
router.HandleFunc("/raw", ok).Methods(http.MethodGet)
subrouter := router.PathPrefix("/api/v3").Subrouter()
subrouter.Handle("/protected-sub", handler.New(ok, handler.OpenAPIDef{ID: "ProtectedSub", SuccessStatusCode: http.StatusNoContent})).Methods(http.MethodGet)
subrouter.HandleFunc("/raw-sub", ok).Methods(http.MethodGet)
router.PathPrefix("/").Handler(http.HandlerFunc(ok))
return router
}
testCases := []struct {
name string
path string
withClaims bool
status int
}{
{name: "AnonymousHandlerWithoutClaims", path: "/anonymous", withClaims: false, status: http.StatusNoContent},
{name: "RawCatchAllWithoutClaims", path: "/index.html", withClaims: false, status: http.StatusNoContent},
{name: "RawWithoutClaims", path: "/raw", withClaims: false, status: http.StatusNoContent},
{name: "RawSubrouterWithoutClaims", path: "/api/v3/raw-sub", withClaims: false, status: http.StatusNoContent},
{name: "ProtectedHandlerWithoutClaims", path: "/protected", withClaims: false, status: http.StatusUnauthorized},
{name: "ProtectedSubrouterWithoutClaims", path: "/api/v3/protected-sub", withClaims: false, status: http.StatusUnauthorized},
{name: "ProtectedHandlerWithClaims", path: "/protected", withClaims: true, status: http.StatusNoContent},
{name: "ProtectedSubrouterWithClaims", path: "/api/v3/protected-sub", withClaims: true, status: http.StatusNoContent},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
newRouter(testCase.withClaims).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, testCase.path, nil))
assert.Equal(t, testCase.status, recorder.Code)
})
}
}

View File

@@ -150,12 +150,6 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
})
}
func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
next(rw, req)
})
}
// CheckResources authorizes every resolved resource for the route. roles are the
// allowed role names (the OSS role-gate); the resource selectors drive the EE check.
func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string) http.HandlerFunc {

View File

@@ -401,9 +401,9 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v2/traces/fields", am.ViewAccess(aH.traceFields)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/traces/fields", am.EditAccess(aH.updateTraceField)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/version", am.OpenAccess(aH.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/features", am.OpenAccess(aH.getFeatureFlags)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/health", am.OpenAccess(aH.getHealth)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/version", aH.getVersion).Methods(http.MethodGet)
router.HandleFunc("/api/v1/features", aH.getFeatureFlags).Methods(http.MethodGet)
router.HandleFunc("/api/v1/health", aH.getHealth).Methods(http.MethodGet)
router.HandleFunc("/api/v1/listErrors", am.ViewAccess(aH.listErrors)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/countErrors", am.ViewAccess(aH.countErrors)).Methods(http.MethodPost)
@@ -418,7 +418,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSourceFilters)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.Signoz.Handlers.QuickFilter.UpdateQuickFilters)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/register", aH.registerUser).Methods(http.MethodPost)
router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost)

View File

@@ -59,14 +59,14 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorAPIKey}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorAPIKey}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorAPIKey}, WildCardSelectorString)},
// factor-password — admin can issue and inspect reset tokens; users change their own password via OpenAccess
// factor-password — admin can issue and inspect reset tokens; users change their own password through /api/v2/users/me/factor_password, which has no role check
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
// license — admin only.
// Uniform LCRUD shape served by /api/v4/licenses: create = Activate,
// update = Refresh, read = Get (includes the key), list, delete (non-cloud
// licenses only). GET /api/v4/licenses/active is OpenAccess, so the read
// licenses only). GET /api/v4/licenses/active has no role check, so the read
// grant is not enforced there.
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},

View File

@@ -0,0 +1,41 @@
from http import HTTPStatus
import pytest
import requests
from fixtures import types
@pytest.mark.parametrize(
"path",
[
"/api/v2/users/me",
"/api/v1/stats",
"/api/v1/features",
"/api/v2/features",
"/api/v4/licenses/active",
"/api/v2/dashboards",
],
)
def test_protected_routes_reject_missing_credentials(signoz: types.SigNoz, path: str) -> None:
response = requests.get(signoz.self.host_configs["8080"].get(path), timeout=5)
assert response.status_code == HTTPStatus.UNAUTHORIZED, response.text
assert response.json()["error"]["code"] == "unauthenticated"
@pytest.mark.parametrize(
"path",
[
"/api/v1/version",
"/api/v1/health",
"/api/v2/healthz",
"/api/v2/readyz",
"/api/v2/livez",
"/api/v1/global/config",
],
)
def test_public_routes_serve_missing_credentials(signoz: types.SigNoz, path: str) -> None:
response = requests.get(signoz.self.host_configs["8080"].get(path), timeout=5)
assert response.status_code == HTTPStatus.OK, response.text