Compare commits

..

6 Commits

Author SHA1 Message Date
swapnil-signoz
01cc8ec1d1 Merge branch 'main' into issue_5324 2026-06-11 18:36:41 +05:30
swapnil-signoz
8fe3f769c2 refactor: adding missing metrics in data collected and panels in dash 2026-06-11 18:36:05 +05:30
Naman Verma
7eb0095133 fix: proper definition of user dashboard preferences (#11643)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix: proper definition of user dashboard preferences

* fix: use org id in deletion methods of pref table

* fix: make migration name fit regex

* fix: make compile return empty sql instead of nil

* fix: remove dashboard dependency from user module

* test: remove cleanup fixture from integration test
2026-06-11 12:50:32 +00:00
Naman Verma
df26eb1c1d chore: make some fields required in perses replicated spec (#11612)
* chore: make some fields required in perses replicated spec

* chore: build frondend spec

* revert: revert accidental change

* fix: make duration optional

* chore: add todo for duration and refresh interval
2026-06-11 12:17:25 +00:00
Vikrant Gupta
36334309bb feat(resource): add resource middleware (#11607)
* feat(resource): initial commit

* feat(resource): add related resources

* feat(resource): audit cleanup

* refactor(resource): set audit category on resource defs; drop MustNew/validate

- Set Category (access_control) on every service account and role ResourceDef
  so audit events carry signoz.audit.action_category
- Remove MustNewResourceDef/MustNewResourcesDef and validate(); registration
  via plain ResourceDef literals again. Validation to be revisited separately

* feat(audit): emit audit events only for mutating verbs

- Add coretypes.Verb.IsMutation() (create/update/delete/attach/detach)
- Audit skips read/list defs (they remain for authz); failed and denied
  mutations still emit with Outcome=failure

* feat(audit): mirror attach/detach audit on both ends for role↔service account

Role def on SetRole/DeleteRole now carries Related=ServiceAccount so each
permission-checked end emits its own event (serviceaccount.attached to role
and role.attached to serviceaccount), matching the both-ends authz model.

* refactor(resource): co-locate resolved context in handler; slice-of-pointers accessor

Move the resolved-resource context plumbing (resolvedKey, accessors) out of
the resource middleware and into pkg/http/handler next to ResolvedResource, so
type and accessor live in one package (matching the authtypes/ctxtypes
convention) and consumers import a single package.

- Store []*ResolvedResource instead of *[]ResolvedResource; in-place response-id
  finalization still works via the element pointers.
- ResolvedResourcesFromContext returns an error (errCodeResolvedResourcesNotFound)
  instead of a bool; authz surfaces it, audit treats absence as a no-op.
- Drop the now-dead authz Check/CheckAll/AuthZCheckGroup helpers superseded by
  CheckResources.

* refactor(resource): unify id resolution into a single phase-driven mechanism

Replace the two-shaped id mechanism (a resolved string plus a stashed
responseID extractor, decided by resolveID's magic tuple and a zero-value
sentinel) with one retained extractor whose phase decides when it runs.

- ResolvedResource/ResolvedRelated keep idExtractor (renamed from responseID);
  it is run in its declared phase, never re-run.
- ResourceIDExtractor gains isPhase + runFor; ResolvedResource gains resolve,
  called once per phase (request by the resource middleware, response by audit).
- resolveID and resolveRelated(ec) are gone; FinalizeResponseIDs collapses to a
  single resolve(phaseResponse) call. Request and response resolution are now
  symmetric.

* refactor(resource): split resourcedef.go along its logical seams

Break the ~320-line resourcedef.go into cohesive files within the handler
package (pure relocation, no behavior or API change):

- extractor.go        — extraction: ExtractorContext, phases, extractors + constructors
- resourcedef.go      — declaration: ResourceDef/ResourcesDef/RelatedResource/
                         ResourceSpec + their functions (resolveRequest, ResolveRequest)
                         and the selectors
- resolved_resource.go — resolved types + their functions (resolve,
                         newResolvedRelated, FinalizeResponseIDs, HasResponseIDs)
- resolved_context.go — context plumbing (resolvedKey + accessors)

Each file's imports narrow to its concern; mux/gjson are now confined to
extractor.go.

* refactor(resource): extract selectors into selector.go

Move SelectorFunc + WildcardSelector/IDSelector (and the errCode they use)
out of resourcedef.go into selector.go. Pure relocation, no behavior change:
resourcedef.go now holds only the route-author declaration types and narrows
its imports to audittypes + coretypes.

* refactor(resource): extract ResourceSpec into resource_spec.go

Move the sealed ResourceSpec interface out of resourcedef.go into its own
file. Pure relocation, no behavior change.

* refactor(resource): split ResourcesDef into resourcesdef.go

Move the fan-out ResourcesDef (struct + sealResourceSpec/resolveRequest) out
of resourcedef.go into its own file. resourcedef.go keeps ResourceDef, the
shared RelatedResource, and the ResolveRequest orchestrator. Pure relocation,
no behavior change.

* refactor(resource): move RelatedResource and ResolveRequest into resource_spec.go

Cluster the spec contract together: the shared RelatedResource type and the
ResolveRequest orchestrator (over []ResourceSpec) join the ResourceSpec
interface. resourcedef.go now holds only ResourceDef. Pure relocation, no
behavior change.

* refactor(resource): seal ResourceSpec via resolveRequest alone

Drop the redundant sealResourceSpec() marker method; the unexported
resolveRequest already prevents implementations outside the package.

* feat(resource): scaffold coretypes-based resolved model

Introduce the referenceable, coretypes-resident resource model (additive;
the existing ResourceDef path is untouched and the build stays green):

- coretypes: ExtractorContext + ExtractPhase + ResourceIDExtractor/
  ResourceIDsExtractor (extractor machinery moved out of handler; handler keeps
  only the mux/gjson constructors).
- coretypes: SelectorFunc (now (ctx, resource, id, orgID) to stay cycle-free) +
  WildcardSelector/IDSelector.
- coretypes: ResolvedResource + ResolvedResourceWithTargetResource interfaces,
  their concrete types with two-phase fill (request ids at construction,
  response ids via ResolveResponse), and the resolved-context accessors.
- handler: the three explicit declaration types — BasicResourceDef,
  AttachDetachSiblingResourceDef, AttachDetachParentChildResourceDef.

Wiring (defs -> ResolveRequest, middleware, route migration) follows next.

* refactor(resource): wire the coretypes resolved model end-to-end

Cut the resource middleware over to the coretypes-resident resolved model and
the explicit declaration types, replacing the generic ResourceDef/ResourcesDef.

- handler: ResourceDef is now a sealed interface (unexported resolveRequest)
  implemented by BasicResourceDef / AttachDetachSiblingResourceDef /
  AttachDetachParentChildResourceDef, all consolidated into resourcedef.go.
  Removed the old generic defs, the handler-side resolved/selector/context
  (moved to coretypes), and the dead AuditDef.
- coretypes: ActionCategory moved here; Category() exposed on the resolved
  interface (declared on the def, read by audit; no kind-based derivation).
- middleware: authz does M+N absolute checks (source always, sibling target
  too, parent-child child never) via the resolved selectors; audit type-switches
  on the resolved interface to emit per resource / per relationship.
- authz forbidden message is now AWS-style: principal is not authorized to
  perform <kind>:<verb> on resource "<id>".
- routes: service account + role routes migrated to the explicit defs;
  roleSelector takes orgID.

Note: resourcedef_test.go (old API) removed; new tests to follow.

* feat(resource): instrument query-range with telemetry resource authz

Authorize /api/v5/query_range at the telemetry-resource level, derived from the
request body rather than a path/body id:

- coretypes: ResourceExtractor now yields []ResourceWithID (resource + id), and
  TelemetrySignalSource maps each query's spec.signal+spec.source to a telemetry
  resource (via TelemetryResourceForSignalSource) and reads a per-query id — one
  entry per query, no de-duplication, so repeated signals each get their own
  resource + id.
- handler: TelemetryResourceDef fans out one resolved resource per query through
  NewResolvedResourceWithID; resolveRequest returns a slice to allow fan-out.
- The extractor model (types + constructors + ResourceExtractor) now lives wholly
  in coretypes (handler/extractor.go removed); coretypes gains mux/gjson.
- querier route: ViewAccess -> CheckResources + the telemetry def (spec.name is a
  placeholder id; the owner picks the real field).

Carries the in-progress removal of Verb.IsMutation and its audit mutation-gate,
so audit currently emits per resolved resource regardless of verb (to revisit).

* feat(resource): instrument planned-maintenance routes + tidy resolved id handling

- ruler.go: downtime_schedules routes move from ViewAccess/EditAccess to
  CheckResources with resource defs — Basic for list/read/create/update/delete on
  PlannedMaintenance, plus a sibling Attach (schedule <-> the rules in alertIds)
  on create/update so both the schedule and each rule are authz-checked.
- coretypes: SourceIDs/TargetIDs return a single empty id when there are none, so
  collection-level access lives in the resolved value; authz.checkResource drops
  its empty-id shim and just iterates.
- readability: expand crammed multi-arg signatures and calls (checkResource,
  NewResolvedResource/WithID, forbidden errors.Newf, telemetry mapping) to one
  argument per line.

* refactor(resource): drop query-range/planned-maintenance instrumentation; mirror sibling audit

- Revert /api/v5/query_range and downtime_schedules routes to ViewAccess/EditAccess
  and remove the telemetry-resource scaffolding that only query-range consumed
  (TelemetryResourceDef, TelemetrySignalSource, TelemetryResourceForSignalSource,
  ResourceExtractor/ResourceWithID, NewResolvedResourceWithID).
- audit: a sibling attach/detach now emits the event from both ends, matching the
  both-ends authz model (parent-child stays one-directional).
- Strip non-essential doc/inline comments across the resource middleware files.

* refactor(coretypes): fold extractor/selector _func files into their concept files

- Merge extractor_func.go + extractor_context.go into extractor.go, and
  selector_func.go into selector.go, matching the type.go/object.go/verb.go
  convention of keeping a type with its constructors and helpers.
- Order each file const/var -> type -> func (also reorders action_category.go).

* feat(resource): capture response body only when an id is resolved from it

- Restore the capture gate lost in the coretypes move: ResolvedResource gains an
  unexported hasResponsePhase(), and ShouldCaptureResponseBody(ctx) drives the
  audit middleware so the body is buffered only when some resolved resource reads
  an id out of it (e.g. a create), not for every resource-declared route.
- Add ResourceIDsExtractor.IsPhase (mirroring ResourceIDExtractor) and reuse it.
- Fold resolved_context.go into resolved.go.

---------

Co-authored-by: grandwizard28 <vibhupandey28@gmail.com>
2026-06-11 10:57:56 +00:00
swapnil-signoz
e6d8713e1c feat: adding support for Azure SQL DB 2026-06-11 15:19:52 +05:30
68 changed files with 6259 additions and 1626 deletions

View File

@@ -470,6 +470,25 @@ components:
role:
type: string
type: object
AuthtypesAuthDomainConfig:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesAuthNProvider:
enum:
- google_auth
@@ -496,48 +515,6 @@ components:
nullable: true
type: array
type: object
AuthtypesAuthProviderEnvelope:
discriminator:
mapping:
google_auth: '#/components/schemas/AuthtypesAuthProviderGoogle'
oidc: '#/components/schemas/AuthtypesAuthProviderOIDC'
saml: '#/components/schemas/AuthtypesAuthProviderSAML'
propertyName: type
oneOf:
- $ref: '#/components/schemas/AuthtypesAuthProviderSAML'
- $ref: '#/components/schemas/AuthtypesAuthProviderOIDC'
- $ref: '#/components/schemas/AuthtypesAuthProviderGoogle'
type: object
AuthtypesAuthProviderGoogle:
properties:
config:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
type:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
required:
- type
- config
type: object
AuthtypesAuthProviderOIDC:
properties:
config:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
type:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
required:
- type
- config
type: object
AuthtypesAuthProviderSAML:
properties:
config:
$ref: '#/components/schemas/AuthtypesSAMLConfig'
type:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
required:
- type
- config
type: object
AuthtypesCallbackAuthNSupport:
properties:
provider:
@@ -549,6 +526,8 @@ components:
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
createdAt:
format: date-time
type: string
@@ -558,12 +537,6 @@ components:
type: string
orgId:
type: string
provider:
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
ssoEnabled:
type: boolean
updatedAt:
format: date-time
type: string
@@ -661,14 +634,10 @@ components:
type: object
AuthtypesPostableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
name:
type: string
provider:
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
ssoEnabled:
type: boolean
type: object
AuthtypesPostableEmailPasswordSession:
properties:
@@ -741,7 +710,7 @@ components:
useRoleAttribute:
type: boolean
type: object
AuthtypesSAMLConfig:
AuthtypesSamlConfig:
properties:
attributeMapping:
$ref: '#/components/schemas/AuthtypesAttributeMapping'
@@ -776,12 +745,8 @@ components:
type: object
AuthtypesUpdatableAuthDomain:
properties:
provider:
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
ssoEnabled:
type: boolean
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
type: object
AuthtypesUserRole:
properties:
@@ -1399,6 +1364,7 @@ components:
- appservice
- containerapp
- aks
- sqldatabase
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -2471,13 +2437,6 @@ components:
url:
type: string
type: object
DashboardPanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
@@ -2605,13 +2564,12 @@ components:
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
type: object
display:
$ref: '#/components/schemas/CommonDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
duration:
type: string
layouts:
items:
$ref: '#/components/schemas/DashboardtypesLayout'
nullable: true
type: array
links:
items:
@@ -2620,7 +2578,6 @@ components:
panels:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesPanel'
nullable: true
type: object
refreshInterval:
type: string
@@ -2628,6 +2585,11 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesVariable'
type: array
required:
- display
- variables
- panels
- layouts
type: object
DashboardtypesDatasourcePlugin:
discriminator:
@@ -2663,6 +2625,15 @@ components:
plugin:
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
type: object
DashboardtypesDisplay:
properties:
description:
type: string
name:
type: string
required:
- name
type: object
DashboardtypesDynamicVariableSpec:
properties:
name:
@@ -2857,7 +2828,7 @@ components:
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
display:
$ref: '#/components/schemas/VariableDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
type: string
plugin:
@@ -2865,6 +2836,8 @@ components:
sort:
nullable: true
type: string
required:
- display
type: object
DashboardtypesListableDashboardForUserV2:
properties:
@@ -2992,7 +2965,7 @@ components:
DashboardtypesListedDashboardV2Spec:
properties:
display:
$ref: '#/components/schemas/CommonDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
type: object
DashboardtypesNumberPanelSpec:
properties:
@@ -3012,6 +2985,9 @@ components:
$ref: '#/components/schemas/DashboardtypesPanelKind'
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelFormatting:
properties:
@@ -3141,7 +3117,7 @@ components:
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/DashboardPanelDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
links:
items:
$ref: '#/components/schemas/DashboardLink'
@@ -3151,7 +3127,12 @@ components:
queries:
items:
$ref: '#/components/schemas/DashboardtypesQuery'
nullable: true
type: array
required:
- display
- plugin
- queries
type: object
DashboardtypesPatchOp:
enum:
@@ -3220,6 +3201,9 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPlugin:
discriminator:
@@ -3326,6 +3310,8 @@ components:
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
required:
- plugin
type: object
DashboardtypesQueryVariableSpec:
properties:

View File

@@ -53,7 +53,7 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().Provider.Type != authtypes.AuthNProviderOIDC {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
}
@@ -106,14 +106,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, err
}
if claims == nil && authDomain.AuthDomainConfig().Oidc().GetUserInfo {
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
if err != nil {
return nil, err
}
}
emailClaim, ok := claims[authDomain.AuthDomainConfig().Oidc().ClaimMapping.Email].(string)
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
}
@@ -123,7 +123,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
}
if !authDomain.AuthDomainConfig().Oidc().InsecureSkipEmailVerified {
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
emailVerifiedClaim, ok := claims["email_verified"].(bool)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
@@ -135,14 +135,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
name := ""
if nameClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Name; nameClaim != "" {
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if n, ok := claims[nameClaim].(string); ok {
name = n
}
}
var groups []string
if groupsClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Groups; groupsClaim != "" {
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
if claimValue, exists := claims[groupsClaim]; exists {
switch g := claimValue.(type) {
case []any:
@@ -161,7 +161,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
role := ""
if roleClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Role; roleClaim != "" {
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if r, ok := claims[roleClaim].(string); ok {
role = r
}
@@ -177,11 +177,11 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
}
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
if authDomain.AuthDomainConfig().Oidc().IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().Oidc().IssuerAlias)
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
}
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().Oidc().Issuer)
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
if err != nil {
return nil, nil, err
}
@@ -194,8 +194,8 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
}
return oidcProvider, &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().Oidc().ClientID,
ClientSecret: authDomain.AuthDomainConfig().Oidc().ClientSecret,
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
Endpoint: oidcProvider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{
@@ -212,7 +212,7 @@ func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.Aut
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
}
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Oidc().ClientID})
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())

View File

@@ -40,7 +40,7 @@ func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Li
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().Provider.Type != authtypes.AuthNProviderSAML {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
}
@@ -101,19 +101,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
}
name := ""
if nameAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Name; nameAttribute != "" {
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
name = val
}
}
var groups []string
if groupAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Groups; groupAttribute != "" {
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
groups = assertionInfo.Values.GetAll(groupAttribute)
}
role := ""
if roleAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Role; roleAttribute != "" {
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
role = val
}
@@ -142,11 +142,11 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
// For AWSSSO, this is the value of Application SAML audience.
return &saml2.SAMLServiceProvider{
IdentityProviderSSOURL: authDomain.AuthDomainConfig().Saml().SamlIdp,
IdentityProviderIssuer: authDomain.AuthDomainConfig().Saml().SamlEntity,
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
ServiceProviderIssuer: siteURL.Host,
AssertionConsumerServiceURL: acsURL.String(),
SignAuthnRequests: !authDomain.AuthDomainConfig().Saml().InsecureSkipAuthNRequestsSigned,
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
AllowMissingAttributes: true,
IDPCertificateStore: certStore,
SPKeyStore: dsig.RandomKeyStoreForTest(),
@@ -159,15 +159,15 @@ func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509
}
var certBytes []byte
if strings.Contains(authDomain.AuthDomainConfig().Saml().SamlCert, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().Saml().SamlCert))
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
if block == nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
}
certBytes = block.Bytes
} else {
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().Saml().SamlCert)
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
if err != nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
}

View File

@@ -254,12 +254,12 @@ func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID value
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, userID, id)
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, userID)
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {

View File

@@ -185,6 +185,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -2655,6 +2655,7 @@ export enum CloudintegrationtypesServiceIDDTO {
appservice = 'appservice',
containerapp = 'containerapp',
aks = 'aks',
sqldatabase = 'sqldatabase',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -3156,17 +3157,6 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface DashboardPanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
@@ -3892,6 +3882,17 @@ export type DashboardtypesDashboardSpecDTODatasources = {
export enum DashboardtypesPanelKindDTO {
Panel = 'Panel',
}
export interface DashboardtypesDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
}
@@ -4440,42 +4441,36 @@ export interface DashboardtypesQuerySpecDTO {
* @type string
*/
name?: string;
plugin?: DashboardtypesQueryPluginDTO;
plugin: DashboardtypesQueryPluginDTO;
}
export interface DashboardtypesQueryDTO {
kind?: Querybuildertypesv5RequestTypeDTO;
spec?: DashboardtypesQuerySpecDTO;
kind: Querybuildertypesv5RequestTypeDTO;
spec: DashboardtypesQuerySpecDTO;
}
export interface DashboardtypesPanelSpecDTO {
display?: DashboardPanelDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type array
*/
links?: DashboardLinkDTO[];
plugin?: DashboardtypesPanelPluginDTO;
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array
* @type array,null
*/
queries?: DashboardtypesQueryDTO[];
queries: DashboardtypesQueryDTO[] | null;
}
export interface DashboardtypesPanelDTO {
kind?: DashboardtypesPanelKindDTO;
spec?: DashboardtypesPanelSpecDTO;
kind: DashboardtypesPanelKindDTO;
spec: DashboardtypesPanelSpecDTO;
}
export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
export type DashboardtypesDashboardSpecDTOPanels = {
[key: string]: DashboardtypesPanelDTO;
};
/**
* @nullable
*/
export type DashboardtypesDashboardSpecDTOPanels =
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
Grid = 'Grid',
}
@@ -4572,7 +4567,7 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display?: VariableDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
*/
@@ -4614,23 +4609,23 @@ export interface DashboardtypesDashboardSpecDTO {
* @type object
*/
datasources?: DashboardtypesDashboardSpecDTODatasources;
display?: CommonDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
*/
duration?: string;
/**
* @type array,null
* @type array
*/
layouts?: DashboardtypesLayoutDTO[] | null;
layouts: DashboardtypesLayoutDTO[];
/**
* @type array
*/
links?: DashboardLinkDTO[];
/**
* @type object,null
* @type object
*/
panels?: DashboardtypesDashboardSpecDTOPanels;
panels: DashboardtypesDashboardSpecDTOPanels;
/**
* @type string
*/
@@ -4638,7 +4633,7 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
variables?: DashboardtypesVariableDTO[];
variables: DashboardtypesVariableDTO[];
}
export enum DashboardtypesDatasourcePluginKindDTO {
@@ -4762,7 +4757,7 @@ export enum DashboardtypesListSortDTO {
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: CommonDisplayDTO;
display?: DashboardtypesDisplayDTO;
}
export interface DashboardtypesListedDashboardForUserV2DTO {

View File

@@ -133,6 +133,10 @@ function DashboardsList(): JSX.Element {
tags: null,
spec: {
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
layouts: [],
panels: {},
variables: [],
// TODO(@AshwinBhatkal): duration and refresh interval need to be integrated
},
});
safeNavigate(

View File

@@ -50,8 +50,8 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
)
}
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
// Health endpoints don't act on resources.
return nil
}

View File

@@ -7,166 +7,197 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addRoleRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateRole",
Tags: []string{"role"},
Summary: "Create role",
Description: "This endpoint creates a role",
Request: new(authtypes.PostableRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListRoles",
Tags: []string{"role"},
Summary: "List roles",
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetRole",
Tags: []string{"role"},
Summary: "Get role",
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.GetObjects, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Patch, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.PatchObjects, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.PatchObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func roleCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleInstanceSelectorCallback(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -1,13 +1,10 @@
package signozapiserver
import (
"bytes"
"encoding/json"
"io"
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -17,41 +14,56 @@ import (
)
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
Description: "This endpoint lists the service accounts for an organisation",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -72,89 +84,117 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
Description: "This endpoint gets an existing service account",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.GetRoles, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
Description: "This endpoint gets all the roles for the existing service account",
Request: nil,
RequestContentType: "",
Response: new([]*authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetRoles, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
Description: "This endpoint gets all the roles for the existing service account",
Request: nil,
RequestContentType: "",
Response: new([]*authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.SetRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleAttachSelectorFromBody, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("id")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.DeleteRole, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleDetachSelectorFromPath, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.PathParam("rid")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -175,208 +215,209 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Update, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
Description: "This endpoint updates an existing service account",
Request: new(serviceaccounttypes.UpdatableServiceAccount),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Update, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
Description: "This endpoint updates an existing service account",
Request: new(serviceaccounttypes.UpdatableServiceAccount),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
Description: "This endpoint deletes an existing service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
Description: "This endpoint deletes an existing service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.CreateFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbCreate}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyCollectionSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
Description: "This endpoint creates a service account key",
Request: new(serviceaccounttypes.PostableFactorAPIKey),
RequestContentType: "",
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceServiceAccount,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyCollectionSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
Description: "This endpoint lists the service account keys",
Request: nil,
RequestContentType: "",
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyInstanceSelectorCallback, []string{
authtypes.SigNozAdminRoleName,
}), handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
Description: "This endpoint updates an existing service account key",
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
Description: "This endpoint updates an existing service account key",
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("fid"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.RevokeFactorAPIKey, []middleware.AuthZCheckGroup{
{{Relation: authtypes.Relation{Verb: coretypes.VerbDelete}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
authtypes.SigNozAdminRoleName,
}}},
}), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",
Description: "This endpoint revokes an existing service account key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("fid"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceServiceAccount,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
ChildIDs: coretypes.OneID(coretypes.PathParam("fid")),
},
),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func (provider *provider) roleDetachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["rid"])
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
// already extracted by the ResourceDef (path or body); this only does the
// UUID -> name lookup the FGA object string requires. Shared by service account
// and role routes.
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(id)
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
role, err := provider.authzService.Get(ctx, orgID, roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleAttachSelectorFromBody(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body = io.NopCloser(bytes.NewReader(body))
postableRole := new(serviceaccounttypes.PostableServiceAccountRole)
if err := json.Unmarshal(body, postableRole); err != nil {
return nil, err
}
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), postableRole.ID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
coretypes.TypeRole.MustSelector(role.Name),
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func factorAPIKeyInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
fid := mux.Vars(req)["fid"]
fidSelector, err := coretypes.TypeMetaResource.Selector(fid)
if err != nil {
return nil, err
}
return []coretypes.Selector{
fidSelector,
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
return []coretypes.Selector{
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
id := mux.Vars(req)["id"]
idSelector, err := coretypes.TypeServiceAccount.Selector(id)
if err != nil {
return nil, err
}
return []coretypes.Selector{
idSelector,
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
resource.Type().MustSelector(role.Name),
resource.Type().MustSelector(coretypes.WildCardSelectorString),
}, nil
}

View File

@@ -20,16 +20,16 @@ func newTestSettings() factory.ScopedProviderSettings {
return factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "auditorserver_test")
}
func newTestEvent(resource string, action coretypes.Verb) audittypes.AuditEvent {
func newTestEvent(resource coretypes.Resource, action coretypes.Verb) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(coretypes.MustNewKind(resource), action),
EventName: audittypes.NewEventName(resource.Kind(), action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
ResourceKind: coretypes.MustNewKind(resource),
Resource: resource,
},
}
}
@@ -84,7 +84,7 @@ func TestAdd_FlushesOnBatchSize(t *testing.T) {
go func() { _ = server.Start(ctx) }()
for i := 0; i < 3; i++ {
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
}
assert.Eventually(t, func() bool {
@@ -113,7 +113,7 @@ func TestAdd_FlushesOnInterval(t *testing.T) {
go func() { _ = server.Start(ctx) }()
server.Add(ctx, newTestEvent("user", coretypes.VerbUpdate))
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate))
assert.Eventually(t, func() bool {
return exported.Load() == 1
@@ -131,9 +131,9 @@ func TestAdd_DropsWhenBufferFull(t *testing.T) {
ctx := context.Background()
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbUpdate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbDelete))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbUpdate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete))
assert.Equal(t, 2, server.queueLen())
}
@@ -156,7 +156,7 @@ func TestStop_DrainsRemainingEvents(t *testing.T) {
go func() { _ = server.Start(ctx) }()
for i := 0; i < 5; i++ {
server.Add(ctx, newTestEvent("alert-rule", coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceRule, coretypes.VerbCreate))
}
require.NoError(t, server.Stop(ctx))
@@ -181,8 +181,8 @@ func TestAdd_ContinuesAfterExportFailure(t *testing.T) {
go func() { _ = server.Start(ctx) }()
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
assert.Eventually(t, func() bool {
return calls.Load() >= 1
@@ -213,7 +213,7 @@ func TestAdd_ConcurrentSafety(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
}()
}
wg.Wait()

View File

@@ -59,7 +59,7 @@ func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *auth
return "", err
}
if authDomain.AuthDomainConfig().Provider.Type != authtypes.AuthNProviderGoogleAuth {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderGoogleAuth {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not google")
}
@@ -111,7 +111,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google: no id_token in token response")
}
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google().ClientID})
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: failed to verify token", errors.Attr(err))
@@ -135,7 +135,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim")
}
if !authDomain.AuthDomainConfig().Google().InsecureSkipEmailVerified {
if !authDomain.AuthDomainConfig().Google.InsecureSkipEmailVerified {
if !claims.EmailVerified {
a.settings.Logger().ErrorContext(ctx, "google: email is not verified", slog.String("email", claims.Email))
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: email is not verified")
@@ -148,14 +148,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
var groups []string
if authDomain.AuthDomainConfig().Google().FetchGroups {
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google())
if authDomain.AuthDomainConfig().Google.FetchGroups {
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google)
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: could not fetch groups", errors.Attr(err))
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: could not fetch groups").WithAdditional(err.Error())
}
allowedGroups := authDomain.AuthDomainConfig().Google().AllowedGroups
allowedGroups := authDomain.AuthDomainConfig().Google.AllowedGroups
if len(allowedGroups) > 0 {
groups = filterGroups(groups, allowedGroups)
if len(groups) == 0 {
@@ -175,8 +175,8 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain, provider *oidc.Provider) *oauth2.Config {
return &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().Google().ClientID,
ClientSecret: authDomain.AuthDomainConfig().Google().ClientSecret,
ClientID: authDomain.AuthDomainConfig().Google.ClientID,
ClientSecret: authDomain.AuthDomainConfig().Google.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{

View File

@@ -15,13 +15,13 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
AuditDef() *AuditDef
ResourceDefs() []ResourceDef
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
resourceDefs []ResourceDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
@@ -130,6 +130,6 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
}
}
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
func (handler *handler) ResourceDefs() []ResourceDef {
return handler.resourceDefs
}

View File

@@ -1,25 +1,9 @@
package handler
import (
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
// Option configures optional behaviour on a handler created by New.
type Option func(*handler)
type AuditDef struct {
ResourceKind coretypes.Kind // Typeable.Kind() value, e.g. "dashboard", "user".
Action coretypes.Verb // create, update, delete, etc.
Category audittypes.ActionCategory // access_control, configuration_change, etc.
ResourceIDParam string // Gorilla mux path param name for the resource ID.
}
// WithAudit attaches an AuditDef to the handler. The actual audit event
// emission is handled by the middleware layer, which reads the AuditDef
// from the matched route's handler.
func WithAuditDef(def AuditDef) Option {
func WithResourceDefs(defs ...ResourceDef) Option {
return func(h *handler) {
h.auditDef = &def
h.resourceDefs = append(h.resourceDefs, defs...)
}
}

View File

@@ -0,0 +1,99 @@
package handler
import "github.com/SigNoz/signoz/pkg/types/coretypes"
type ResourceDef interface {
// resolveRequest is unexported to seal the interface. It returns a slice so a
// single def can fan out (e.g. a telemetry query touching multiple signals).
resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource
}
func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
resolved := make([]coretypes.ResolvedResource, 0, len(defs))
for _, def := range defs {
resolved = append(resolved, def.resolveRequest(ec)...)
}
return resolved
}
// BasicResourceDef checks a single resource for one verb.
type BasicResourceDef struct {
Resource coretypes.Resource
Verb coretypes.Verb
Category coretypes.ActionCategory
ID coretypes.ResourceIDExtractor
Selector coretypes.SelectorFunc
}
func (def BasicResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResource(
def.Verb,
def.Category,
def.Resource,
def.ID,
def.Selector,
ec,
),
}
}
// AttachDetachSiblingResourceDef checks an attach/detach between peer resources;
// both source and target are authz-checked.
type AttachDetachSiblingResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
SourceResource coretypes.Resource
SourceIDs coretypes.ResourceIDsExtractor
SourceSelector coretypes.SelectorFunc
TargetResource coretypes.Resource
TargetIDs coretypes.ResourceIDsExtractor
TargetSelector coretypes.SelectorFunc
}
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
),
}
}
// AttachDetachParentChildResourceDef authz-checks only the parent; the child
// rides along for audit context.
type AttachDetachParentChildResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
ParentResource coretypes.Resource
ParentID coretypes.ResourceIDExtractor
ParentSelector coretypes.SelectorFunc
ChildResource coretypes.Resource
ChildIDs coretypes.ResourceIDsExtractor
}
func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.ParentResource,
coretypes.OneID(def.ParentID),
def.ParentSelector,
def.ChildResource,
def.ChildIDs,
nil,
true,
ec,
),
}
}

View File

@@ -12,10 +12,10 @@ import (
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
const (
@@ -61,6 +61,12 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
responseBuffer := &byteBuffer{}
writer := newResponseCapture(rw, responseBuffer)
// Capture the body only when a resolved resource derives an id from it (e.g. a create).
if coretypes.ShouldCaptureResponseBody(req.Context()) {
writer.EnableBodyCapture()
}
next.ServeHTTP(writer, req)
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
@@ -80,7 +86,7 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
fields = append(fields, errors.Attr(writeErr))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
if responseBuffer.Len() != 0 {
if statusCode >= 400 && responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
@@ -94,76 +100,85 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
return
}
def := auditDefFromRequest(req)
if def == nil {
resolved, err := coretypes.ResolvedResourcesFromContext(req.Context())
if err != nil || len(resolved) == 0 {
return
}
// extract claims
claims, _ := authtypes.ClaimsFromContext(req.Context())
// extract status code
statusCode := writer.StatusCode()
// extract traces.
span := trace.SpanFromContext(req.Context())
// extract error details.
var errorType, errorCode string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
event := audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
def.Action,
def.Category,
claims,
resourceIDFromRequest(req, def.ResourceIDParam),
def.ResourceKind,
errorType,
errorCode,
)
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
middleware.auditor.Audit(req.Context(), event)
}
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
// The type assertion is necessary because route.GetHandler() returns
// http.Handler, and not every http.Handler on the mux is a handler.Handler
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.AuditDef()
}
func resourceIDFromRequest(req *http.Request, param string) string {
if param == "" {
return ""
}
vars := mux.Vars(req)
if vars == nil {
return ""
}
return vars[param]
for _, resource := range resolved {
resource.ResolveResponse(extractorCtx)
verb, category := resource.Verb(), resource.Category()
switch typed := resource.(type) {
case coretypes.ResolvedResourceWithTargetResource:
for _, sourceID := range typed.SourceIDs() {
for _, targetID := range typed.TargetIDs() {
attributesList := []audittypes.ResourceAttributes{
audittypes.NewRelatedResourceAttributes(
typed.SourceResource(),
sourceID,
typed.TargetResource(),
targetID,
),
}
// Sibling peers are symmetric, so mirror the event from the target's side too.
if !typed.IsParentChild() {
attributesList = append(attributesList, audittypes.NewRelatedResourceAttributes(
typed.TargetResource(),
targetID,
typed.SourceResource(),
sourceID,
))
}
for _, attributes := range attributesList {
middleware.auditor.Audit(req.Context(), audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
verb,
category,
claims,
attributes,
errorType,
errorCode,
))
}
}
}
default:
for _, id := range resource.SourceIDs() {
attributes := audittypes.NewResourceAttributes(resource.SourceResource(), id)
middleware.auditor.Audit(req.Context(), audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
verb,
category,
claims,
attributes,
errorType,
errorCode,
))
}
}
}
}

View File

@@ -1,6 +1,8 @@
package middleware
import (
"context"
"fmt"
"log/slog"
"net/http"
@@ -19,18 +21,6 @@ const (
authzDeniedMessage string = "::AUTHZ-DENIED::"
)
type AuthZCheckDef struct {
Relation authtypes.Relation
Resource coretypes.Resource
SelectorCallback selectorCallbackWithClaimsFn
Roles []string
}
// AuthZCheckGroup is a set of checks OR'd together.
// At least one check in the group must pass for the group to pass.
type AuthZCheckGroup []AuthZCheckDef
type selectorCallbackWithClaimsFn func(*http.Request, authtypes.Claims) ([]coretypes.Selector, error)
type selectorCallbackWithoutClaimsFn func(*http.Request, []*types.Organization) ([]coretypes.Selector, valuer.UUID, error)
type AuthZ struct {
@@ -201,7 +191,9 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
})
}
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithClaimsFn, roles []string) http.HandlerFunc {
// 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 {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
@@ -210,40 +202,7 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
return
}
selectors, err := cb(req, claims)
if err != nil {
render.Error(rw, err)
return
}
roleSelectors := []coretypes.Selector{}
for _, role := range roles {
roleSelectors = append(roleSelectors, coretypes.TypeRole.MustSelector(role))
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, valuer.MustNewUUID(claims.OrgID), relation, typeable, selectors, roleSelectors)
if err != nil {
render.Error(rw, err)
return
}
next(rw, req)
})
}
// CheckAll verifies groups of permission checks.
// Within each group, checks are OR'd (any check passing = group passes).
// Across groups, results are AND'd (all groups must pass).
//
// This model expresses any combination:
// - Single check: []AuthZCheckGroup{{checkA}}
// - Pure AND: []AuthZCheckGroup{{checkA}, {checkB}}
// - Cross-resource OR: []AuthZCheckGroup{{checkA, checkB}}
// - Mixed (A OR B) AND C: []AuthZCheckGroup{{checkA, checkB}, {checkC}}
func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGroup) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
resolved, err := coretypes.ResolvedResourcesFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
@@ -251,33 +210,23 @@ func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGrou
orgID := valuer.MustNewUUID(claims.OrgID)
for _, group := range groups {
groupPassed := false
var lastErr error
roleSelectors := make([]coretypes.Selector, len(roles))
for idx, role := range roles {
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
}
for _, check := range group {
selectors, err := check.SelectorCallback(req, claims)
if err != nil {
for _, resource := range resolved {
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return
}
target, ok := resource.(coretypes.ResolvedResourceWithTargetResource)
if ok && !target.IsParentChild() {
if err := middleware.checkResource(ctx, claims, orgID, target.Verb(), target.TargetResource(), target.TargetIDs(), target.TargetSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return
}
roleSelectors := make([]coretypes.Selector, len(check.Roles))
for idx, role := range check.Roles {
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, orgID, check.Relation, check.Resource, selectors, roleSelectors)
if err == nil {
groupPassed = true
break
}
lastErr = err
}
if !groupPassed {
render.Error(rw, lastErr)
return
}
}
@@ -285,6 +234,68 @@ func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGrou
})
}
func (middleware *AuthZ) checkResource(
ctx context.Context,
claims authtypes.Claims,
orgID valuer.UUID,
verb coretypes.Verb,
resource coretypes.Resource,
ids []string,
selector coretypes.SelectorFunc,
roleSelectors []coretypes.Selector,
) error {
if selector == nil {
return errors.New(errors.TypeInternal, errors.CodeInternal, "resolved resource is missing a selector")
}
for _, id := range ids {
selectors, err := selector(ctx, resource, id, orgID)
if err != nil {
return err
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
orgID,
authtypes.Relation{Verb: verb},
resource,
selectors,
roleSelectors,
)
if err == nil {
continue
}
if !errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
return err
}
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
principal := fmt.Sprintf("%s/%s", claims.Principal.StringValue(), claims.IdentityID())
if id != "" {
return errors.Newf(
errors.TypeForbidden,
authtypes.ErrCodeAuthZForbidden,
"%s is not authorized to perform %s on resource %q",
principal,
resource.Scope(verb),
id,
)
}
return errors.Newf(
errors.TypeForbidden,
authtypes.ErrCodeAuthZForbidden,
"%s is not authorized to perform %s",
principal,
resource.Scope(verb),
)
}
return nil
}
func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithoutClaimsFn, roles []string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -0,0 +1,67 @@
package middleware
import (
"bytes"
"io"
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
)
// Resource resolves a route's declared ResourceDefs and stashes the result in
// the request context for authz and audit to read.
type Resource struct {
logger *slog.Logger
}
func NewResource(logger *slog.Logger) *Resource {
return &Resource{logger: logger.With(slog.String("pkg", pkgname))}
}
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
defs := resourceDefsFromRequest(req)
if len(defs) == 0 {
next.ServeHTTP(rw, req)
return
}
// Buffer the body once so extractors can read it and the handler still sees a fresh reader.
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(body))
}
extractorCtx := coretypes.ExtractorContext{
Request: req,
RequestBody: body,
}
resolved := handler.ResolveRequest(defs, extractorCtx)
ctx := coretypes.NewContextWithResolvedResources(req.Context(), resolved)
next.ServeHTTP(rw, req.WithContext(ctx))
})
}
func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.ResourceDefs()
}

View File

@@ -23,9 +23,14 @@ type responseCapture interface {
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
// BodyBytes returns the captured response body bytes. Populated for error
// responses (status >= 400), or for any response once EnableBodyCapture is called.
BodyBytes() []byte
// EnableBodyCapture forces capture of the response body regardless of status
// code (still bounded by maxResponseBodyCapture). Must be called before the
// handler writes the response.
EnableBodyCapture()
}
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
@@ -72,12 +77,13 @@ func (b *byteBuffer) String() string {
}
type nonFlushingResponseCapture struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
forceCaptureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
@@ -98,13 +104,17 @@ func (writer *nonFlushingResponseCapture) Header() http.Header {
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 400 {
if statusCode >= 400 || writer.forceCaptureBody {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
func (writer *nonFlushingResponseCapture) EnableBodyCapture() {
writer.forceCaptureBody = true
}
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
if writer.statusCode == 0 {

View File

@@ -38,7 +38,7 @@ func (handler *handler) Create(rw http.ResponseWriter, req *http.Request) {
return
}
authDomain, err := authtypes.NewAuthDomainFromConfig(body.Name, &body.AuthDomainConfig, valuer.MustNewUUID(claims.OrgID))
authDomain, err := authtypes.NewAuthDomainFromConfig(body.Name, &body.Config, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
@@ -154,7 +154,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = authDomain.Update(&body.AuthDomainConfig)
err = authDomain.Update(&body.Config)
if err != nil {
render.Error(rw, err)
return

View File

@@ -27,7 +27,7 @@ func (module *module) Get(ctx context.Context, id valuer.UUID) (*authtypes.AuthD
}
func (module *module) GetAuthNProviderInfo(ctx context.Context, domain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().Provider.Type].(authn.CallbackAuthN); ok {
if callbackAuthN, ok := module.authNs[domain.AuthDomainConfig().AuthNProvider].(authn.CallbackAuthN); ok {
return callbackAuthN.ProviderInfo(ctx, domain)
}
return &authtypes.AuthNProviderInfo{}
@@ -62,7 +62,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats := make(map[string]any)
for _, domain := range domains {
key := "authdomain." + domain.AuthDomainConfig().Provider.Type.StringValue() + ".count"
key := "authdomain." + domain.AuthDomainConfig().AuthNProvider.StringValue() + ".count"
if value, ok := stats[key]; ok {
stats[key] = value.(int64) + 1
} else {

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="f67d1585-6164-4ad0-b2dd-f9cc59b2969f" x1="9.908" y1="15.943" x2="7.516" y2="2.383" gradientUnits="userSpaceOnUse"><stop offset="0.15" stop-color="#0078d4"/><stop offset="0.8" stop-color="#5ea0ef"/><stop offset="1" stop-color="#83b9f9"/></linearGradient></defs><g id="a4fd1868-54fe-4ca6-8ff6-3b01866dc27b"><path d="M14.49,7.15A5.147,5.147,0,0,0,9.24,2.164,5.272,5.272,0,0,0,4.216,5.653,4.869,4.869,0,0,0,0,10.4a4.946,4.946,0,0,0,5.068,4.814H13.82A4.292,4.292,0,0,0,18,11.127,4.105,4.105,0,0,0,14.49,7.15Z" fill="url(#f67d1585-6164-4ad0-b2dd-f9cc59b2969f)"/><path d="M12.9,11.4V8H12v4.13h2.46V11.4ZM5.76,9.73a1.825,1.825,0,0,1-.51-.31.441.441,0,0,1-.12-.32.342.342,0,0,1,.15-.3.683.683,0,0,1,.42-.12,1.62,1.62,0,0,1,1,.29V8.11a2.58,2.58,0,0,0-1-.16,1.641,1.641,0,0,0-1.09.34,1.08,1.08,0,0,0-.42.89c0,.51.32.91,1,1.21a2.907,2.907,0,0,1,.62.36.419.419,0,0,1,.15.32.381.381,0,0,1-.16.31.806.806,0,0,1-.45.11,1.66,1.66,0,0,1-1.09-.42V12a2.173,2.173,0,0,0,1.07.24,1.877,1.877,0,0,0,1.18-.33A1.08,1.08,0,0,0,6.84,11a1.048,1.048,0,0,0-.25-.7A2.425,2.425,0,0,0,5.76,9.73ZM11,11.32A2.191,2.191,0,0,0,11,9a1.808,1.808,0,0,0-.7-.75,2,2,0,0,0-1-.26,2.112,2.112,0,0,0-1.08.27A1.856,1.856,0,0,0,7.49,9a2.465,2.465,0,0,0-.26,1.14,2.256,2.256,0,0,0,.24,1,1.766,1.766,0,0,0,.69.74,2.056,2.056,0,0,0,1,.3l.86,1h1.21L10,12.08A1.79,1.79,0,0,0,11,11.32Zm-1-.25a.941.941,0,0,1-.76.35.916.916,0,0,1-.76-.36,1.523,1.523,0,0,1-.29-1,1.529,1.529,0,0,1,.29-1,1,1,0,0,1,.78-.37.869.869,0,0,1,.75.37,1.619,1.619,0,0,1,.27,1A1.459,1.459,0,0,1,10,11.07Z" fill="#f2f2f2"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,565 @@
{
"id": "sqldatabase",
"title": "Azure SQL Database",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_cpu_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of CPU used by the database workload, relative to its limit."
},
{
"name": "azure_cpu_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of CPU used by the database workload, relative to its limit."
},
{
"name": "azure_cpu_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of CPU used by the database workload, relative to its limit."
},
{
"name": "azure_sql_instance_cpu_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Total CPU usage (user plus system) of the SQL instance, as a percentage."
},
{
"name": "azure_sql_instance_cpu_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Total CPU usage (user plus system) of the SQL instance, as a percentage."
},
{
"name": "azure_sql_instance_cpu_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Total CPU usage (user plus system) of the SQL instance, as a percentage."
},
{
"name": "azure_sql_instance_memory_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Memory usage of the SQL instance, as a percentage of its limit."
},
{
"name": "azure_sql_instance_memory_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Memory usage of the SQL instance, as a percentage of its limit."
},
{
"name": "azure_sql_instance_memory_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Memory usage of the SQL instance, as a percentage of its limit."
},
{
"name": "azure_physical_data_read_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Data file IO usage as a percentage of the limit."
},
{
"name": "azure_physical_data_read_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Data file IO usage as a percentage of the limit."
},
{
"name": "azure_physical_data_read_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Data file IO usage as a percentage of the limit."
},
{
"name": "azure_log_write_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Transaction log write throughput as a percentage of the limit."
},
{
"name": "azure_log_write_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Transaction log write throughput as a percentage of the limit."
},
{
"name": "azure_log_write_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Transaction log write throughput as a percentage of the limit."
},
{
"name": "azure_workers_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Worker threads in use as a percentage of the limit."
},
{
"name": "azure_workers_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Worker threads in use as a percentage of the limit."
},
{
"name": "azure_workers_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Worker threads in use as a percentage of the limit."
},
{
"name": "azure_sessions_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Active sessions as a percentage of the limit."
},
{
"name": "azure_sessions_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Active sessions as a percentage of the limit."
},
{
"name": "azure_sessions_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Active sessions as a percentage of the limit."
},
{
"name": "azure_sessions_count_average",
"unit": "Count",
"type": "Gauge",
"description": "Number of active sessions."
},
{
"name": "azure_sessions_count_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Number of active sessions."
},
{
"name": "azure_sessions_count_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Number of active sessions."
},
{
"name": "azure_dtu_consumption_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "DTU consumption as a percentage of the limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_consumption_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "DTU consumption as a percentage of the limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_consumption_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "DTU consumption as a percentage of the limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_used_average",
"unit": "Count",
"type": "Gauge",
"description": "DTUs used (DTU-based purchasing model)."
},
{
"name": "azure_dtu_used_maximum",
"unit": "Count",
"type": "Gauge",
"description": "DTUs used (DTU-based purchasing model)."
},
{
"name": "azure_dtu_used_minimum",
"unit": "Count",
"type": "Gauge",
"description": "DTUs used (DTU-based purchasing model)."
},
{
"name": "azure_dtu_limit_average",
"unit": "Count",
"type": "Gauge",
"description": "DTU limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_limit_maximum",
"unit": "Count",
"type": "Gauge",
"description": "DTU limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_limit_minimum",
"unit": "Count",
"type": "Gauge",
"description": "DTU limit (DTU-based purchasing model)."
},
{
"name": "azure_cpu_used_average",
"unit": "Count",
"type": "Gauge",
"description": "vCores used (vCore-based purchasing model)."
},
{
"name": "azure_cpu_used_maximum",
"unit": "Count",
"type": "Gauge",
"description": "vCores used (vCore-based purchasing model)."
},
{
"name": "azure_cpu_used_minimum",
"unit": "Count",
"type": "Gauge",
"description": "vCores used (vCore-based purchasing model)."
},
{
"name": "azure_cpu_limit_average",
"unit": "Count",
"type": "Gauge",
"description": "vCore limit (vCore-based purchasing model)."
},
{
"name": "azure_cpu_limit_maximum",
"unit": "Count",
"type": "Gauge",
"description": "vCore limit (vCore-based purchasing model)."
},
{
"name": "azure_cpu_limit_minimum",
"unit": "Count",
"type": "Gauge",
"description": "vCore limit (vCore-based purchasing model)."
},
{
"name": "azure_storage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space used by the database."
},
{
"name": "azure_storage_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space used by the database."
},
{
"name": "azure_storage_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space used by the database."
},
{
"name": "azure_storage_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Data space used as a percentage of the maximum data size."
},
{
"name": "azure_storage_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Data space used as a percentage of the maximum data size."
},
{
"name": "azure_storage_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Data space used as a percentage of the maximum data size."
},
{
"name": "azure_allocated_data_storage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space allocated to the database (includes unused space)."
},
{
"name": "azure_allocated_data_storage_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space allocated to the database (includes unused space)."
},
{
"name": "azure_allocated_data_storage_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space allocated to the database (includes unused space)."
},
{
"name": "azure_xtp_storage_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "In-Memory OLTP storage used as a percentage of the limit."
},
{
"name": "azure_xtp_storage_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "In-Memory OLTP storage used as a percentage of the limit."
},
{
"name": "azure_xtp_storage_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "In-Memory OLTP storage used as a percentage of the limit."
},
{
"name": "azure_full_backup_size_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative full backup storage size."
},
{
"name": "azure_full_backup_size_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative full backup storage size."
},
{
"name": "azure_full_backup_size_bytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative full backup storage size."
},
{
"name": "azure_diff_backup_size_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative differential backup storage size."
},
{
"name": "azure_diff_backup_size_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative differential backup storage size."
},
{
"name": "azure_diff_backup_size_bytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative differential backup storage size."
},
{
"name": "azure_log_backup_size_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative transaction log backup storage size."
},
{
"name": "azure_log_backup_size_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative transaction log backup storage size."
},
{
"name": "azure_log_backup_size_bytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative transaction log backup storage size."
},
{
"name": "azure_replication_lag_seconds_average",
"unit": "Seconds",
"type": "Gauge",
"description": "Geo-replication lag (RPO) in seconds; reported on the primary database only."
},
{
"name": "azure_replication_lag_seconds_maximum",
"unit": "Seconds",
"type": "Gauge",
"description": "Geo-replication lag (RPO) in seconds; reported on the primary database only."
},
{
"name": "azure_replication_lag_seconds_minimum",
"unit": "Seconds",
"type": "Gauge",
"description": "Geo-replication lag (RPO) in seconds; reported on the primary database only."
},
{
"name": "azure_connection_successful_total",
"unit": "Count",
"type": "Sum",
"description": "Number of successful connections."
},
{
"name": "azure_connection_successful_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of successful connections."
},
{
"name": "azure_connection_failed_total",
"unit": "Count",
"type": "Sum",
"description": "Number of failed connections caused by system errors."
},
{
"name": "azure_connection_failed_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of failed connections caused by system errors."
},
{
"name": "azure_connection_failed_user_error_total",
"unit": "Count",
"type": "Sum",
"description": "Number of failed connections caused by user errors."
},
{
"name": "azure_connection_failed_user_error_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of failed connections caused by user errors."
},
{
"name": "azure_blocked_by_firewall_total",
"unit": "Count",
"type": "Sum",
"description": "Number of connection attempts blocked by the firewall."
},
{
"name": "azure_blocked_by_firewall_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of connection attempts blocked by the firewall."
},
{
"name": "azure_deadlock_total",
"unit": "Count",
"type": "Sum",
"description": "Number of deadlocks."
},
{
"name": "azure_deadlock_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of deadlocks."
},
{
"name": "azure_availability_average",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_availability_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_availability_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_availability_count",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_availability_total",
"unit": "Percent",
"type": "Sum",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_tempdb_data_size_average",
"unit": "Count",
"type": "Gauge",
"description": "Space used in tempdb data files, in kilobytes."
},
{
"name": "azure_tempdb_data_size_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Space used in tempdb data files, in kilobytes."
},
{
"name": "azure_tempdb_data_size_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Space used in tempdb data files, in kilobytes."
},
{
"name": "azure_tempdb_log_size_average",
"unit": "Count",
"type": "Gauge",
"description": "Space used in the tempdb transaction log file, in kilobytes."
},
{
"name": "azure_tempdb_log_size_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Space used in the tempdb transaction log file, in kilobytes."
},
{
"name": "azure_tempdb_log_size_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Space used in the tempdb transaction log file, in kilobytes."
},
{
"name": "azure_tempdb_log_used_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "tempdb transaction log space used as a percentage."
},
{
"name": "azure_tempdb_log_used_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "tempdb transaction log space used as a percentage."
},
{
"name": "azure_tempdb_log_used_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "tempdb transaction log space used as a percentage."
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.Sql",
"resourceType": "servers/databases",
"metrics": {},
"logs": {
"categoryGroups": [
"allLogs"
]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Azure SQL Database Overview",
"description": "Overview of Azure SQL Database metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,7 @@
### Monitor Azure SQL Database with SigNoz
Collect key Azure SQL Database (single database) metrics and view them with an out of the box dashboard.
This integration collects platform metrics for the `Microsoft.Sql/servers/databases` resource type.
Note: This integration is for Azure SQL Database (the PaaS offering). Azure SQL Managed Instance and SQL Server on Azure VMs expose a different set of metrics and are not covered here.

View File

@@ -73,11 +73,11 @@ type Module interface {
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
}
type Handler interface {

View File

@@ -13,9 +13,16 @@ type Compiled struct {
Args []any
}
func (c Compiled) IsEmpty() bool {
return c.SQL == ""
}
// Compile always returns a non-nil *Compiled. An empty query (or one that
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
// SQL != "" rather than a nil check.
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(query) == 0 {
return nil, nil //nolint:nilnil
return &Compiled{}, nil
}
queryVisitor := newVisitor(formatter)
@@ -29,9 +36,6 @@ func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
if sql == "" {
return nil, nil //nolint:nilnil
}
return &Compiled{
SQL: sql,

View File

@@ -17,7 +17,7 @@ import (
type compileCase struct {
subtestName string
dslQueryToCompile string
nilExpected bool
emptyQueryExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
@@ -41,8 +41,8 @@ func runCompileCases(t *testing.T, cases []compileCase) {
}
require.NoError(t, err)
if c.nilExpected {
assert.Nil(t, out)
if c.emptyQueryExpected {
assert.True(t, out.IsEmpty())
return
}
require.NotNil(t, out)
@@ -71,7 +71,7 @@ func runCompileCases(t *testing.T, cases []compileCase) {
func TestCompile_Empty(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "empty query yields nil", dslQueryToCompile: "", nilExpected: true},
{subtestName: "empty query yields nil", dslQueryToCompile: "", emptyQueryExpected: true},
})
}

View File

@@ -103,7 +103,7 @@ func (store *store) ListForUser(
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if compiled != nil {
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
@@ -166,7 +166,7 @@ func (store *store) ListV2(
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if compiled != nil {
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
@@ -383,15 +383,16 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
// rows = 0 is the only signal of a real limit hit.
func (store *store) PinForUser(ctx context.Context, preference *dashboardtypes.UserDashboardPreference) error {
res, err := store.sqlstore.BunDBCtx(ctx).NewRaw(`
INSERT INTO user_dashboard_preference (user_id, dashboard_id, is_pinned)
SELECT ?, ?, true
INSERT INTO user_dashboard_preference (id, user_id, dashboard_id, is_pinned, created_at, updated_at)
SELECT ?, ?, ?, true, ?, ?
WHERE (SELECT COUNT(*) FROM user_dashboard_preference WHERE user_id = ? AND is_pinned = true) < ?
OR EXISTS (SELECT 1 FROM user_dashboard_preference WHERE user_id = ? AND dashboard_id = ? AND is_pinned = true)
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true, updated_at = ?
`,
preference.UserID, preference.DashboardID,
preference.ID, preference.UserID, preference.DashboardID, preference.CreatedAt, preference.UpdatedAt,
preference.UserID, dashboardtypes.MaxPinnedDashboardsPerUser,
preference.UserID, preference.DashboardID,
preference.UpdatedAt,
).Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't pin dashboard for user")
@@ -410,12 +411,21 @@ func (store *store) PinForUser(ctx context.Context, preference *dashboardtypes.U
// UnpinForUser deletes the user's preference row. This is fine while is_pinned
// is the only preference stored; once the row carries other preferences this
// must become an UPDATE that clears is_pinned instead of dropping the row.
func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error {
func (store *store) UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("dashboard").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("user_id = ?", userID).
Where("dashboard_id = ?", dashboardID).
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't unpin dashboard for user")
@@ -423,11 +433,19 @@ func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashbo
return nil
}
func (store *store) DeletePreferencesForDashboard(ctx context.Context, dashboardID valuer.UUID) error {
func (store *store) DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("dashboard").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("dashboard_id = ?", dashboardID).
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")
@@ -435,11 +453,19 @@ func (store *store) DeletePreferencesForDashboard(ctx context.Context, dashboard
return nil
}
func (store *store) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
func (store *store) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
userIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("users").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("user_id = ?", userID).
Where("user_id IN (?)", userIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")

View File

@@ -304,7 +304,7 @@ func (handler *handler) pinUnpinV2(rw http.ResponseWriter, r *http.Request, pin
if pin {
err = handler.module.PinV2(ctx, orgID, userID, dashboardID)
} else {
err = handler.module.UnpinV2(ctx, userID, dashboardID)
err = handler.module.UnpinV2(ctx, orgID, userID, dashboardID)
}
if err != nil {
render.Error(rw, err)

View File

@@ -119,7 +119,7 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
if err := existing.ErrIfNotUpdatable(); err != nil {
return nil, err
}
@@ -154,7 +154,7 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
if err := existing.ErrIfNotUpdatable(); err != nil {
return nil, err
}
@@ -193,7 +193,7 @@ func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer
if err != nil {
return err
}
if err := existing.CanDelete(); err != nil {
if err := existing.ErrIfNotDeletable(); err != nil {
return err
}
@@ -202,7 +202,7 @@ func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer
if _, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, nil); err != nil {
return err
}
if err := module.store.DeletePreferencesForDashboard(ctx, id); err != nil {
if err := module.store.DeletePreferencesForDashboard(ctx, orgID, id); err != nil {
return err
}
return module.store.Delete(ctx, orgID, id)
@@ -231,10 +231,10 @@ func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID value
return module.store.PinForUser(ctx, dashboardtypes.NewUserDashboardPreference(userID, id))
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, userID, id)
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, userID)
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
}

View File

@@ -201,7 +201,7 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddPasswordAuthNSupport(authtypes.AuthNProviderEmailPassword), nil
}
provider, err := getProvider[authn.CallbackAuthN](authDomain.AuthDomainConfig().Provider.Type, module.authNs)
provider, err := getProvider[authn.CallbackAuthN](authDomain.AuthDomainConfig().AuthNProvider, module.authNs)
if err != nil {
return nil, err
}
@@ -211,7 +211,7 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
return nil, err
}
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.AuthDomainConfig().Provider.Type, loginURL), nil
return authtypes.NewOrgSessionContext(org.ID, org.Name).AddCallbackAuthNSupport(authDomain.AuthDomainConfig().AuthNProvider, loginURL), nil
}
func getProvider[T authn.AuthN](authNProvider authtypes.AuthNProvider, authNs map[authtypes.AuthNProvider]authn.AuthN) (T, error) {

View File

@@ -13,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/tokenizer"
@@ -35,11 +34,11 @@ type setter struct {
analytics analytics.Analytics
config root.Config
getter root.Getter
dashboard dashboard.Module
onDeleteUser []root.OnDeleteUser
}
// This module is a WIP, don't take inspiration from this.
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter, dashboard dashboard.Module) root.Setter {
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter, onDeleteUser []root.OnDeleteUser) root.Setter {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &setter{
store: store,
@@ -52,7 +51,7 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
authz: authz,
config: config,
getter: getter,
dashboard: dashboard,
onDeleteUser: onDeleteUser,
}
}
@@ -409,8 +408,10 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
if err := module.dashboard.DeletePreferencesForUser(ctx, user.ID); err != nil {
return err
for _, onDeleteUser := range module.onDeleteUser {
if err := onDeleteUser(ctx, orgID, user.ID); err != nil {
return err
}
}
traitsOrProperties := types.NewTraitsFromUser(user)

View File

@@ -129,3 +129,6 @@ type Handler interface {
ChangePassword(http.ResponseWriter, *http.Request)
ForgotPassword(http.ResponseWriter, *http.Request)
}
// OnDeleteUser lets other modules clean up data tied to a deleted user.
type OnDeleteUser func(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error

View File

@@ -168,6 +168,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -122,7 +122,11 @@ func NewModules(
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, dashboard)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
}
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, onDeleteUser)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{

View File

@@ -212,7 +212,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateAuthDomainPayloadFactory(),
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
)
}

View File

@@ -1,118 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateAuthDomainPayload struct{}
type authDomainPayloadRaw struct {
bun.BaseModel `bun:"table:auth_domain"`
ID string `bun:"id"`
Data string `bun:"data"`
}
// auth config type -> old sso type.
var legacyConfigKeyByType = map[string]string{
"saml": "samlConfig",
"oidc": "oidcConfig",
"google_auth": "googleAuthConfig",
}
func NewMigrateAuthDomainPayloadFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_auth_domain_payload"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateAuthDomainPayload{}, nil
},
)
}
func (migration *migrateAuthDomainPayload) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *migrateAuthDomainPayload) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var rows []*authDomainPayloadRaw
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
var oldData map[string]json.RawMessage
if err := json.Unmarshal([]byte(row.Data), &oldData); err != nil {
return err
}
// idempotency - we skip the ones which already migrated.
if _, hasProvider := oldData["provider"]; hasProvider {
continue
}
if _, hasSSOType := oldData["ssoType"]; !hasSSOType {
continue
}
var ssoType string
if err := json.Unmarshal(oldData["ssoType"], &ssoType); err != nil {
return err
}
provider := map[string]json.RawMessage{
"type": oldData["ssoType"],
}
// get from old data and set config in provider.
if configKey, ok := legacyConfigKeyByType[ssoType]; ok {
if cfg, ok := oldData[configKey]; ok {
provider["config"] = cfg
}
}
providerRaw, err := json.Marshal(provider)
if err != nil {
return err
}
updatedData := map[string]json.RawMessage{
"provider": providerRaw,
}
if v, ok := oldData["ssoEnabled"]; ok {
updatedData["ssoEnabled"] = v
}
if v, ok := oldData["roleMapping"]; ok {
updatedData["roleMapping"] = v
}
updatedDataRaw, err := json.Marshal(updatedData)
if err != nil {
return err
}
row.Data = string(updatedDataRaw)
if _, err := tx.NewUpdate().Model(row).Column("data").Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *migrateAuthDomainPayload) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,84 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type recreateUserDashboardPreference struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewRecreateUserDashboardPreferenceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("recreate_user_dashboard_pref"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &recreateUserDashboardPreference{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *recreateUserDashboardPreference) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up replaces the composite (user_id, dashboard_id) primary key with a surrogate
// id primary key, demotes the pair to a unique index, and adds created_at /
// updated_at. The table is dropped and recreated since it carries no data yet.
func (migration *recreateUserDashboardPreference) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().DropTable(&sqlschema.Table{Name: "user_dashboard_preference"})
sqls = append(sqls, migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "user_dashboard_preference",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "is_pinned", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("user_id"),
ReferencedTableName: sqlschema.TableName("users"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})...)
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "user_dashboard_preference",
ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"},
})...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *recreateUserDashboardPreference) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -13,13 +13,13 @@ import (
// Audit attributes — Action (What).
type AuditAttributes struct {
Action coretypes.Verb // guaranteed to be present
ActionCategory ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
Action coretypes.Verb // guaranteed to be present
ActionCategory coretypes.ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
IdentNProvider authtypes.IdentNProvider
}
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category ActionCategory, claims authtypes.Claims) AuditAttributes {
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory, claims authtypes.Claims) AuditAttributes {
outcome := OutcomeFailure
if statusCode >= 200 && statusCode < 400 {
outcome = OutcomeSuccess
@@ -71,23 +71,50 @@ func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
// Audit attributes — Resource (On What).
// These are OTel resource attributes (placed on the Resource, not event attributes).
type ResourceAttributes struct {
ResourceID string
ResourceKind coretypes.Kind // guaranteed to be present
Resource coretypes.Resource // guaranteed to be present
ResourceID string
// TargetResource names the counterpart of an attach/detach event (audit
// context only). nil when there is no relationship.
TargetResource coretypes.Resource
TargetResourceID string
}
func NewResourceAttributes(resourceID string, resourceKind coretypes.Kind) ResourceAttributes {
func NewResourceAttributes(resource coretypes.Resource, resourceID string) ResourceAttributes {
return ResourceAttributes{
ResourceID: resourceID,
ResourceKind: resourceKind,
Resource: resource,
ResourceID: resourceID,
}
}
// NewAttachResourceAttributes builds resource attributes that additionally name
// the target counterpart (used for attach/detach audit events).
func NewRelatedResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) ResourceAttributes {
return ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
TargetResource: targetResource,
TargetResourceID: targetResourceID,
}
}
// PutResource writes the resource attributes to an OTel Resource's attribute map.
// These are resource-level attributes (stored in the resource JSON column),
// not event-level attributes (stored in attributes_string).
func (attributes ResourceAttributes) PutResource(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.ResourceKind.String())
func (attributes ResourceAttributes) PutResource(orgID valuer.UUID, dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.Resource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
if attributes.ResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.object", attributes.Resource.Object(orgID, attributes.ResourceID))
}
if attributes.TargetResource != nil {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.kind", attributes.TargetResource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.target.id", attributes.TargetResourceID)
if attributes.TargetResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.object", attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
}
}
}
// Audit attributes — Error (When outcome is failure)
@@ -193,13 +220,24 @@ func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttri
// Resource: " kind (id)" or " kind".
b.WriteString(" ")
b.WriteString(resourceAttributes.ResourceKind.String())
b.WriteString(resourceAttributes.Resource.Kind().String())
if resourceAttributes.ResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.ResourceID)
b.WriteString(")")
}
// Target (attach/detach context): " · target kind (id)" or " · target kind".
if resourceAttributes.TargetResource != nil {
b.WriteString(" to ")
b.WriteString(resourceAttributes.TargetResource.Kind().String())
if resourceAttributes.TargetResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.TargetResourceID)
b.WriteString(")")
}
}
// Error suffix (failure only): ": type (code)" or ": type" or ": (code)" or omitted.
if auditAttributes.Outcome == OutcomeFailure {
errorType := errorAttributes.ErrorType

View File

@@ -36,7 +36,7 @@ func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, ActionCategoryConfigurationChange, claims)
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, coretypes.ActionCategoryConfigurationChange, claims)
assert.Equal(t, testCase.expectedOutcome, attrs.Outcome)
})
}
@@ -55,7 +55,7 @@ func TestNewBody(t *testing.T) {
name: "Success_EmptyResourceID",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDelete,
ActionCategory: ActionCategoryConfigurationChange,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -63,8 +63,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) deleted dashboard",
@@ -73,7 +73,7 @@ func TestNewBody(t *testing.T) {
name: "Success_EmptyPrincipalEmail",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDelete,
ActionCategory: ActionCategoryConfigurationChange,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -81,8 +81,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "abd",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "019a1234-abcd-7000-8000-567800000001 deleted dashboard (abd)",
@@ -91,7 +91,7 @@ func TestNewBody(t *testing.T) {
name: "Success_EmptyPrincipalIDandEmail",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDelete,
ActionCategory: ActionCategoryConfigurationChange,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -99,8 +99,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "abd",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "deleted dashboard (abd)",
@@ -109,7 +109,7 @@ func TestNewBody(t *testing.T) {
name: "Success_AllPresent",
auditAttributes: AuditAttributes{
Action: coretypes.VerbCreate,
ActionCategory: ActionCategoryConfigurationChange,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -117,8 +117,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("alice@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678)",
@@ -127,21 +127,21 @@ func TestNewBody(t *testing.T) {
name: "Success_EmptyEverythingOptional",
auditAttributes: AuditAttributes{
Action: coretypes.VerbUpdate,
ActionCategory: ActionCategoryConfigurationChange,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: ResourceAttributes{
ResourceKind: coretypes.MustNewKind("alert-rule"),
Resource: coretypes.ResourceMetaResourceRule,
},
errorAttributes: ErrorAttributes{},
expectedBody: "updated alert-rule",
expectedBody: "updated rule",
},
{
name: "Failure_AllPresent",
auditAttributes: AuditAttributes{
Action: coretypes.VerbUpdate,
ActionCategory: ActionCategoryConfigurationChange,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{
@@ -149,8 +149,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("viewer@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{
ErrorType: "forbidden",
@@ -169,7 +169,7 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceKind: coretypes.MustNewKind("user"),
Resource: coretypes.ResourceUser,
},
errorAttributes: ErrorAttributes{
ErrorType: "not-found",
@@ -187,8 +187,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
ResourceID: "019b-5678",
Resource: coretypes.ResourceMetaResourceDashboard,
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",

View File

@@ -44,6 +44,8 @@ type AuditEvent struct {
TransportAttributes TransportAttributes
}
// NewAuditEvent builds an audit event from pre-built resource attributes (which
// may carry attach/target context).
func NewAuditEventFromHTTPRequest(
req *http.Request,
route string,
@@ -51,16 +53,14 @@ func NewAuditEventFromHTTPRequest(
traceID oteltrace.TraceID,
spanID oteltrace.SpanID,
action coretypes.Verb,
actionCategory ActionCategory,
actionCategory coretypes.ActionCategory,
claims authtypes.Claims,
resourceID string,
resourceKind coretypes.Kind,
resourceAttributes ResourceAttributes,
errorType string,
errorCode string,
) AuditEvent {
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory, claims)
principalAttributes := NewPrincipalAttributesFromClaims(claims)
resourceAttributes := NewResourceAttributes(resourceID, resourceKind)
errorAttributes := NewErrorAttributes(errorType, errorCode)
transportAttributes := NewTransportAttributesFromHTTP(req, route, statusCode)
@@ -69,7 +69,7 @@ func NewAuditEventFromHTTPRequest(
TraceID: traceID,
SpanID: spanID,
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, errorAttributes),
EventName: NewEventName(resourceAttributes.ResourceKind, auditAttributes.Action),
EventName: NewEventName(resourceAttributes.Resource.Kind(), auditAttributes.Action),
AuditAttributes: auditAttributes,
PrincipalAttributes: principalAttributes,
ResourceAttributes: resourceAttributes,
@@ -89,7 +89,7 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
groups := make(map[resourceKey][]int)
order := make([]resourceKey, 0)
for i, event := range events {
key := resourceKey{kind: event.ResourceAttributes.ResourceKind.String(), id: event.ResourceAttributes.ResourceID}
key := resourceKey{kind: event.ResourceAttributes.Resource.Kind().String(), id: event.ResourceAttributes.ResourceID}
if _, exists := groups[key]; !exists {
order = append(order, key)
}
@@ -101,7 +101,8 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
resourceAttrs := resourceLogs.Resource().Attributes()
resourceAttrs.PutStr(string(semconv.ServiceNameKey), name)
resourceAttrs.PutStr(string(semconv.ServiceVersionKey), version)
events[groups[key][0]].ResourceAttributes.PutResource(resourceAttrs)
head := events[groups[key][0]]
head.ResourceAttributes.PutResource(head.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
scopeLogs := resourceLogs.ScopeLogs().AppendEmpty()
scopeLogs.Scope().SetName(scope)

View File

@@ -12,10 +12,10 @@ import (
)
var (
testDashboardKind = coretypes.MustNewKind("dashboard")
testDashboardResource = coretypes.ResourceMetaResourceDashboard
)
func TestNewAuditEventFromHTTPRequest(t *testing.T) {
func TestNewAuditEvent(t *testing.T) {
traceID := oteltrace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
spanID := oteltrace.SpanID{1, 2, 3, 4, 5, 6, 7, 8}
@@ -26,10 +26,10 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
route string
statusCode int
action coretypes.Verb
category ActionCategory
category coretypes.ActionCategory
claims authtypes.Claims
resource coretypes.Resource
resourceID string
resourceKind coretypes.Kind
errorType string
errorCode string
expectedOutcome Outcome
@@ -42,10 +42,10 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
route: "/api/v1/dashboards",
statusCode: http.StatusOK,
action: coretypes.VerbCreate,
category: ActionCategoryConfigurationChange,
category: coretypes.ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "alice@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resource: testDashboardResource,
resourceID: "019b-5678-efgh-9012",
resourceKind: testDashboardKind,
expectedOutcome: OutcomeSuccess,
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678-efgh-9012)",
},
@@ -56,10 +56,10 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
route: "/api/v1/dashboards/{id}",
statusCode: http.StatusForbidden,
action: coretypes.VerbUpdate,
category: ActionCategoryConfigurationChange,
category: coretypes.ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019aaaaa-bbbb-7000-8000-cccc00000002", Email: "viewer@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resource: testDashboardResource,
resourceID: "019b-5678-efgh-9012",
resourceKind: testDashboardKind,
errorType: "forbidden",
errorCode: "authz_forbidden",
expectedOutcome: OutcomeFailure,
@@ -80,15 +80,14 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
testCase.action,
testCase.category,
testCase.claims,
testCase.resourceID,
testCase.resourceKind,
NewResourceAttributes(testCase.resource, testCase.resourceID),
testCase.errorType,
testCase.errorCode,
)
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
assert.Equal(t, testCase.expectedBody, event.Body)
assert.Equal(t, testCase.resourceKind, event.ResourceAttributes.ResourceKind)
assert.Equal(t, testCase.resource.Kind(), event.ResourceAttributes.Resource.Kind())
assert.Equal(t, testCase.resourceID, event.ResourceAttributes.ResourceID)
assert.Equal(t, testCase.action, event.AuditAttributes.Action)
assert.Equal(t, testCase.category, event.AuditAttributes.ActionCategory)
@@ -103,18 +102,18 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
}
}
func newTestEvent(resourceKind coretypes.Kind, resourceID string, action coretypes.Verb) AuditEvent {
func newTestEvent(resource coretypes.Resource, resourceID string, action coretypes.Verb) AuditEvent {
return AuditEvent{
Body: resourceKind.String() + "." + action.PastTense(),
EventName: NewEventName(resourceKind, action),
Body: resource.Kind().String() + "." + action.PastTense(),
EventName: NewEventName(resource.Kind(), action),
AuditAttributes: AuditAttributes{
Action: action,
ActionCategory: ActionCategoryConfigurationChange,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
ResourceAttributes: ResourceAttributes{
ResourceKind: resourceKind,
ResourceID: resourceID,
Resource: resource,
ResourceID: resourceID,
},
}
}
@@ -136,7 +135,7 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SingleEvent",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
},
expectedResourceLogs: 1,
expectedResourceKinds: []string{"dashboard"},
@@ -146,9 +145,9 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SameResource_MultipleEvents",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
},
expectedResourceLogs: 1,
expectedResourceKinds: []string{"dashboard"},
@@ -158,8 +157,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "DifferentResources_SeparateGroups",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "user"},
@@ -169,8 +168,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SameKind_DifferentIDs_SeparateGroups",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-002", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-002", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "dashboard"},
@@ -180,11 +179,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "InterleavedResources_GroupedCorrectly",
events: []AuditEvent{
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "user"},
@@ -203,7 +202,6 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
resourceLogs := logs.ResourceLogs().At(i)
resourceAttrs := resourceLogs.Resource().Attributes()
// Verify service resource attributes
serviceName, exists := resourceAttrs.Get("service.name")
assert.True(t, exists)
assert.Equal(t, "signoz", serviceName.Str())
@@ -212,7 +210,6 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, "0.90.0", serviceVersion.Str())
// Verify audit resource attributes on Resource (not event attributes)
kind, exists := resourceAttrs.Get("signoz.audit.resource.kind")
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceKinds[i], kind.Str())
@@ -221,14 +218,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceIDs[i], id.Str())
// Verify scope
assert.Equal(t, 1, resourceLogs.ScopeLogs().Len())
assert.Equal(t, "signoz.audit", resourceLogs.ScopeLogs().At(0).Scope().Name())
// Verify log record count per group
assert.Equal(t, testCase.expectedLogRecordCounts[i], resourceLogs.ScopeLogs().At(0).LogRecords().Len())
// Verify resource attrs are NOT in log record event attributes
for j := 0; j < resourceLogs.ScopeLogs().At(0).LogRecords().Len(); j++ {
recordAttrs := resourceLogs.ScopeLogs().At(0).LogRecords().At(j).Attributes()
_, hasKind := recordAttrs.Get("signoz.audit.resource.kind")

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
"github.com/uptrace/bun"
)
@@ -31,7 +30,7 @@ var (
type GettableAuthDomain struct {
StorableAuthDomain
AuthDomainConfig
Config AuthDomainConfig `json:"config"`
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
@@ -40,12 +39,12 @@ type AuthNProviderInfo struct {
}
type PostableAuthDomain struct {
Name string `json:"name"`
AuthDomainConfig
Config AuthDomainConfig `json:"config"`
Name string `json:"name"`
}
type UpdatableAuthDomain struct {
AuthDomainConfig
Config AuthDomainConfig `json:"config"`
}
type StorableAuthDomain struct {
@@ -58,114 +57,22 @@ type StorableAuthDomain struct {
types.TimeAuditable
}
// TODO: the oneOf emitted by JSONSchemaOneOf is not the shape OpenAPI wants
// for a discriminated union. OpenAPI's discriminator requires every oneOf
// branch to be a $ref to a named component and a sibling property whose value
// selects the variant. ssoType is already discriminator-shaped, but the
// variant payload lives in a sibling field (samlConfig / googleAuthConfig /
// oidcConfig) instead of being the payload itself, so no discriminator can
// be attached. Refactor AuthDomainConfig into an envelope (see
// ruletypes.RuleThresholdData for the pattern) where the chosen config is
// the payload and ssoType is the discriminator.
type AuthDomainConfig struct {
SSOEnabled bool `json:"ssoEnabled"`
RoleMapping *RoleMapping `json:"roleMapping,omitempty"`
Provider AuthProviderEnvelope `json:"provider"`
}
func (config AuthDomainConfig) Saml() *SAMLConfig {
cfg, _ := config.Provider.Config.(*SAMLConfig)
return cfg
}
func (config AuthDomainConfig) Google() *GoogleConfig {
cfg, _ := config.Provider.Config.(*GoogleConfig)
return cfg
}
func (config AuthDomainConfig) Oidc() *OIDCConfig {
cfg, _ := config.Provider.Config.(*OIDCConfig)
return cfg
}
type AuthProviderEnvelope struct {
Type AuthNProvider `json:"type" required:"true"`
Config any `json:"config" required:"true"` // this can be either of SamlConfig, OIDCConfig and GoogleConfig
}
// internal - drives the oneOf thing in open api spec.
type authProviderSAML struct {
Type AuthNProvider `json:"type" required:"true"`
Config SAMLConfig `json:"config" required:"true"`
}
type authProviderOIDC struct {
Type AuthNProvider `json:"type" required:"true"`
Config OIDCConfig `json:"config" required:"true"`
}
type authProviderGoogle struct {
Type AuthNProvider `json:"type" required:"true"`
Config GoogleConfig `json:"config" required:"true"`
}
var (
_ jsonschema.OneOfExposer = AuthProviderEnvelope{}
_ jsonschema.Preparer = AuthProviderEnvelope{}
)
func (AuthProviderEnvelope) JSONSchemaOneOf() []any {
return []any{
authProviderSAML{},
authProviderOIDC{},
authProviderGoogle{},
}
}
func (AuthProviderEnvelope) PrepareJSONSchema(schema *jsonschema.Schema) error {
if schema.ExtraProperties == nil {
schema.ExtraProperties = map[string]any{}
}
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
"propertyName": "type",
"mapping": map[string]string{
"saml": "#/components/schemas/AuthtypesAuthProviderSAML",
"oidc": "#/components/schemas/AuthtypesAuthProviderOIDC",
"google_auth": "#/components/schemas/AuthtypesAuthProviderGoogle",
},
}
return nil
}
func (envelop *AuthProviderEnvelope) UnmarshalJSON(data []byte) error {
var raw struct {
Type AuthNProvider `json:"type"`
Config json.RawMessage `json:"config"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal auth provider: %v", err)
}
envelop.Type = raw.Type
switch raw.Type {
case AuthNProviderSAML:
cfg := new(SAMLConfig)
if err := json.Unmarshal(raw.Config, cfg); err != nil {
return err
}
envelop.Config = cfg
case AuthNProviderOIDC:
cfg := new(OIDCConfig)
if err := json.Unmarshal(raw.Config, cfg); err != nil {
return err
}
envelop.Config = cfg
case AuthNProviderGoogleAuth:
cfg := new(GoogleConfig)
if err := json.Unmarshal(raw.Config, cfg); err != nil {
return err
}
envelop.Config = cfg
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown auth provider type: %s", raw.Type.StringValue())
}
return nil
SSOEnabled bool `json:"ssoEnabled"`
AuthNProvider AuthNProvider `json:"ssoType"`
SAML *SamlConfig `json:"samlConfig"`
Google *GoogleConfig `json:"googleAuthConfig"`
OIDC *OIDCConfig `json:"oidcConfig"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type AuthDomain struct {
@@ -214,7 +121,7 @@ func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain)
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
return &GettableAuthDomain{
StorableAuthDomain: *authDomain.StorableAuthDomain(),
AuthDomainConfig: *authDomain.AuthDomainConfig(),
Config: *authDomain.AuthDomainConfig(),
AuthNProviderInfo: authNProviderInfo,
}
}
@@ -251,14 +158,51 @@ func (typ *PostableAuthDomain) UnmarshalJSON(data []byte) error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidName, "invalid domain name %s", temp.Name)
}
if temp.Provider.Config == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "provider config is required")
}
*typ = PostableAuthDomain(temp)
return nil
}
func (typ *AuthDomainConfig) UnmarshalJSON(data []byte) error {
type Alias AuthDomainConfig
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
switch temp.AuthNProvider {
case AuthNProviderGoogleAuth:
if temp.Google == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "google auth config is required")
}
case AuthNProviderSAML:
if temp.SAML == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "saml config is required")
}
case AuthNProviderOIDC:
if temp.OIDC == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "oidc config is required")
}
default:
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthDomainInvalidConfig, "invalid authn provider %q", temp.AuthNProvider.StringValue())
}
*typ = AuthDomainConfig(temp)
return nil
}
func (AuthDomainConfig) JSONSchemaOneOf() []any {
return []any{
SamlConfig{},
GoogleConfig{},
OIDCConfig{},
}
}
type AuthDomainStore interface {
// Get by id.
Get(context.Context, valuer.UUID) (*AuthDomain, error)

View File

@@ -1,154 +0,0 @@
package authtypes
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
// Verifies the new flat wire shape: ssoType/provider configs collapse into a
// single discriminated `provider:{type,config}`, and the typed payload survives
// a marshal -> unmarshal round-trip. This fails if UnmarshalJSON forgets to
// assign envelop.Config (the decoded config would be lost).
func TestAuthDomainConfigWireRoundTrip(t *testing.T) {
tests := []struct {
name string
provider AuthNProvider
config any
wantType string
assertConfig func(t *testing.T, c AuthDomainConfig)
}{
{
name: "saml",
provider: AuthNProviderSAML,
config: &SAMLConfig{
SamlEntity: "https://idp.example.com",
SamlIdp: "https://idp.example.com/sso",
SamlCert: "cert-bytes",
},
wantType: "saml",
assertConfig: func(t *testing.T, c AuthDomainConfig) {
require.NotNil(t, c.Saml())
require.Equal(t, "https://idp.example.com", c.Saml().SamlEntity)
},
},
{
name: "google",
provider: AuthNProviderGoogleAuth,
config: &GoogleConfig{ClientID: "cid", ClientSecret: "secret"},
wantType: "google_auth",
assertConfig: func(t *testing.T, c AuthDomainConfig) {
require.NotNil(t, c.Google())
require.Equal(t, "cid", c.Google().ClientID)
},
},
{
name: "oidc",
provider: AuthNProviderOIDC,
config: &OIDCConfig{Issuer: "https://issuer", ClientID: "cid", ClientSecret: "secret"},
wantType: "oidc",
assertConfig: func(t *testing.T, c AuthDomainConfig) {
require.NotNil(t, c.Oidc())
require.Equal(t, "https://issuer", c.Oidc().Issuer)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in := AuthDomainConfig{
SSOEnabled: true,
Provider: AuthProviderEnvelope{Type: tt.provider, Config: tt.config},
}
raw, err := json.Marshal(in)
require.NoError(t, err)
js := string(raw)
require.Contains(t, js, `"provider"`)
require.Contains(t, js, `"type":"`+tt.wantType+`"`)
// legacy keys must be gone
require.NotContains(t, js, "ssoType")
require.NotContains(t, js, "samlConfig")
require.NotContains(t, js, "googleAuthConfig")
require.NotContains(t, js, "oidcConfig")
var out AuthDomainConfig
require.NoError(t, json.Unmarshal(raw, &out))
require.True(t, out.SSOEnabled)
require.Equal(t, tt.provider, out.Provider.Type)
tt.assertConfig(t, out)
})
}
}
// Unknown discriminator values are rejected, and the nested provider config's
// own validators still run through the envelope.
func TestAuthDomainConfigUnmarshalRejects(t *testing.T) {
tests := []struct {
name string
json string
}{
{
name: "unknown provider type",
json: `{"ssoEnabled":true,"provider":{"type":"ldap","config":{}}}`,
},
{
name: "oidc config missing clientId",
json: `{"ssoEnabled":true,"provider":{"type":"oidc","config":{"issuer":"https://issuer","clientSecret":"secret"}}}`,
},
{
name: "saml config missing samlEntity",
json: `{"ssoEnabled":true,"provider":{"type":"saml","config":{"samlIdp":"https://idp/sso","samlCert":"abc"}}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var c AuthDomainConfig
require.Error(t, json.Unmarshal([]byte(tt.json), &c))
})
}
}
// The config is marshaled into the `data` column and unmarshaled back when the
// domain is loaded, so the typed provider config must survive that round-trip.
func TestAuthDomainStorageRoundTrip(t *testing.T) {
cfg := AuthDomainConfig{
SSOEnabled: true,
Provider: AuthProviderEnvelope{
Type: AuthNProviderSAML,
Config: &SAMLConfig{SamlEntity: "https://idp", SamlIdp: "https://idp/sso", SamlCert: "abc"},
},
}
domain, err := NewAuthDomainFromConfig("example.com", &cfg, valuer.GenerateUUID())
require.NoError(t, err)
got := domain.AuthDomainConfig()
require.Equal(t, AuthNProviderSAML, got.Provider.Type)
require.NotNil(t, got.Saml())
require.Equal(t, "https://idp", got.Saml().SamlEntity)
}
// Postable keeps name-regex validation and still decodes the embedded provider.
func TestPostableAuthDomainUnmarshal(t *testing.T) {
valid := `{
"name":"example.com",
"ssoEnabled":true,
"provider":{"type":"saml","config":{"samlEntity":"https://idp","samlIdp":"https://idp/sso","samlCert":"abc"}}
}`
var p PostableAuthDomain
require.NoError(t, json.Unmarshal([]byte(valid), &p))
require.Equal(t, "example.com", p.Name)
require.True(t, p.SSOEnabled)
require.NotNil(t, p.Saml())
require.Equal(t, "https://idp", p.Saml().SamlEntity)
invalid := `{"name":"not a domain!","provider":{"type":"saml","config":{"samlEntity":"https://idp","samlIdp":"https://idp/sso","samlCert":"abc"}}}`
var bad PostableAuthDomain
require.Error(t, json.Unmarshal([]byte(invalid), &bad))
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
)
type SAMLConfig struct {
type SamlConfig struct {
// The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">
SamlEntity string `json:"samlEntity"`
@@ -25,8 +25,8 @@ type SAMLConfig struct {
AttributeMapping AttributeMapping `json:"attributeMapping"`
}
func (config *SAMLConfig) UnmarshalJSON(data []byte) error {
type Alias SAMLConfig
func (config *SamlConfig) UnmarshalJSON(data []byte) error {
type Alias SamlConfig
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
@@ -51,6 +51,6 @@ func (config *SAMLConfig) UnmarshalJSON(data []byte) error {
}
}
*config = SAMLConfig(temp)
*config = SamlConfig(temp)
return nil
}

View File

@@ -31,6 +31,7 @@ var (
AzureServiceAppService = ServiceID{valuer.NewString("appservice")}
AzureServiceContainerApp = ServiceID{valuer.NewString("containerapp")}
AzureServiceAKS = ServiceID{valuer.NewString("aks")}
AzureServiceSQLDatabase = ServiceID{valuer.NewString("sqldatabase")}
)
func (ServiceID) Enum() []any {
@@ -54,6 +55,7 @@ func (ServiceID) Enum() []any {
AzureServiceAppService,
AzureServiceContainerApp,
AzureServiceAKS,
AzureServiceSQLDatabase,
}
}
@@ -81,6 +83,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
AzureServiceAppService,
AzureServiceContainerApp,
AzureServiceAKS,
AzureServiceSQLDatabase,
},
}

View File

@@ -1,11 +1,7 @@
package audittypes
package coretypes
import "github.com/SigNoz/signoz/pkg/valuer"
// ActionCategory classifies the audit event per IEC 62443.
// See https://www.iec.ch/blog/understanding-iec-62443 for the standard reference.
type ActionCategory struct{ valuer.String }
var (
ActionCategoryAccessControl = ActionCategory{valuer.NewString("access_control")}
ActionCategoryConfigurationChange = ActionCategory{valuer.NewString("configuration_change")}
@@ -13,6 +9,10 @@ var (
ActionCategorySystemEvent = ActionCategory{valuer.NewString("system_event")}
)
// ActionCategory classifies an audited action per IEC 62443.
// See https://www.iec.ch/blog/understanding-iec-62443 for the standard reference.
type ActionCategory struct{ valuer.String }
func (ActionCategory) Enum() []any {
return []any{
ActionCategoryAccessControl,

View File

@@ -0,0 +1,99 @@
package coretypes
import (
"net/http"
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
)
const (
PhaseRequest ExtractPhase = iota
PhaseResponse
)
type ExtractPhase int
// ExtractorContext carries everything an extractor may read: Request + RequestBody
// are filled pre-handler, ResponseBody post-handler.
type ExtractorContext struct {
Request *http.Request
RequestBody []byte
ResponseBody []byte
}
type ResourceIDExtractor struct {
Phase ExtractPhase
Fn func(ExtractorContext) (string, error)
}
type ResourceIDsExtractor struct {
Phase ExtractPhase
Fn func(ExtractorContext) ([]string, error)
}
func (extractor ResourceIDExtractor) IsPhase(phase ExtractPhase) bool {
return extractor.Fn != nil && extractor.Phase == phase
}
func (extractor ResourceIDExtractor) RunFor(phase ExtractPhase, ec ExtractorContext) (string, bool) {
if !extractor.IsPhase(phase) {
return "", false
}
id, _ := extractor.Fn(ec)
return id, true
}
func (extractor ResourceIDsExtractor) IsPhase(phase ExtractPhase) bool {
return extractor.Fn != nil && extractor.Phase == phase
}
// OneID lifts a single-id extractor into a one-element ids extractor.
func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
return ResourceIDsExtractor{Phase: extractor.Phase, Fn: func(ec ExtractorContext) ([]string, error) {
id, err := extractor.Fn(ec)
if err != nil || id == "" {
return nil, err
}
return []string{id}, nil
}}
}
func PathParam(name string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
return mux.Vars(ec.Request)[name], nil
}}
}
func BodyJSONPath(path string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
return gjson.GetBytes(ec.RequestBody, path).String(), nil
}}
}
func BodyJSONArray(path string) ResourceIDsExtractor {
return ResourceIDsExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) ([]string, error) {
result := gjson.GetBytes(ec.RequestBody, path)
if !result.Exists() {
return nil, nil
}
array := result.Array()
ids := make([]string, 0, len(array))
for _, r := range array {
ids = append(ids, r.String())
}
return ids, nil
}}
}
func ResponseJSONPath(path string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseResponse, Fn: func(ec ExtractorContext) (string, error) {
return gjson.GetBytes(ec.ResponseBody, path).String(), nil
}}
}

View File

@@ -0,0 +1,64 @@
package coretypes
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
)
var errCodeResolvedResourcesNotFound = errors.MustNewCode("resolved_resources_not_found")
type resolvedKey struct{}
// ResolvedResource is the resolved form of a resource def, produced by the
// resource middleware and read by authz and audit.
type ResolvedResource interface {
Verb() Verb
Category() ActionCategory
SourceResource() Resource
SourceIDs() []string
SourceSelector() SelectorFunc
ResolveResponse(ec ExtractorContext)
// hasResponsePhase reports whether an id is resolved from the response body.
hasResponsePhase() bool
}
type ResolvedResourceWithTargetResource interface {
ResolvedResource
TargetResource() Resource
TargetIDs() []string
TargetSelector() SelectorFunc
// IsParentChild true: the target is a child audited along but not authz-checked
// (only the source is); false: a sibling peer that is also authz-checked.
IsParentChild() bool
}
func NewContextWithResolvedResources(ctx context.Context, resolved []ResolvedResource) context.Context {
return context.WithValue(ctx, resolvedKey{}, resolved)
}
func ResolvedResourcesFromContext(ctx context.Context) ([]ResolvedResource, error) {
resolved, ok := ctx.Value(resolvedKey{}).([]ResolvedResource)
if !ok {
return nil, errors.New(errors.TypeInternal, errCodeResolvedResourcesNotFound, "resolved resources not found in context")
}
return resolved, nil
}
// ShouldCaptureResponseBody reports whether any resolved resource in ctx derives
// an id from the response body.
func ShouldCaptureResponseBody(ctx context.Context) bool {
resolved, err := ResolvedResourcesFromContext(ctx)
if err != nil {
return false
}
for _, resource := range resolved {
if resource.hasResponsePhase() {
return true
}
}
return false
}

View File

@@ -0,0 +1,69 @@
package coretypes
type resolvedResource struct {
verb Verb
category ActionCategory
resource Resource
selector SelectorFunc
idExtractor ResourceIDExtractor
ids []string
}
func NewResolvedResource(
verb Verb,
category ActionCategory,
resource Resource,
idExtractor ResourceIDExtractor,
selector SelectorFunc,
ec ExtractorContext,
) ResolvedResource {
resolved := &resolvedResource{
verb: verb,
category: category,
resource: resource,
selector: selector,
idExtractor: idExtractor,
}
resolved.fill(PhaseRequest, ec)
return resolved
}
func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext) {
if id, ok := resolved.idExtractor.RunFor(phase, ec); ok && id != "" {
resolved.ids = []string{id}
}
}
func (resolved *resolvedResource) Verb() Verb {
return resolved.verb
}
func (resolved *resolvedResource) Category() ActionCategory {
return resolved.category
}
func (resolved *resolvedResource) SourceResource() Resource {
return resolved.resource
}
// An empty id (when none resolved) means collection-level access.
func (resolved *resolvedResource) SourceIDs() []string {
if len(resolved.ids) == 0 {
return []string{""}
}
return resolved.ids
}
func (resolved *resolvedResource) SourceSelector() SelectorFunc {
return resolved.selector
}
func (resolved *resolvedResource) ResolveResponse(ec ExtractorContext) {
resolved.fill(PhaseResponse, ec)
}
func (resolved *resolvedResource) hasResponsePhase() bool {
return resolved.idExtractor.IsPhase(PhaseResponse)
}

View File

@@ -0,0 +1,108 @@
package coretypes
type resolvedResourceWithTarget struct {
verb Verb
category ActionCategory
sourceResource Resource
sourceSelector SelectorFunc
sourceExtractor ResourceIDsExtractor
sourceIDs []string
targetResource Resource
targetSelector SelectorFunc
targetExtractor ResourceIDsExtractor
targetIDs []string
parentChild bool
}
func NewResolvedResourceWithTarget(
verb Verb,
category ActionCategory,
sourceResource Resource,
sourceExtractor ResourceIDsExtractor,
sourceSelector SelectorFunc,
targetResource Resource,
targetExtractor ResourceIDsExtractor,
targetSelector SelectorFunc,
parentChild bool,
ec ExtractorContext,
) ResolvedResourceWithTargetResource {
resolved := &resolvedResourceWithTarget{
verb: verb,
category: category,
sourceResource: sourceResource,
sourceSelector: sourceSelector,
sourceExtractor: sourceExtractor,
targetResource: targetResource,
targetSelector: targetSelector,
targetExtractor: targetExtractor,
parentChild: parentChild,
}
resolved.fill(PhaseRequest, ec)
return resolved
}
func (resolved *resolvedResourceWithTarget) fill(phase ExtractPhase, ec ExtractorContext) {
if resolved.sourceExtractor.IsPhase(phase) {
if ids, _ := resolved.sourceExtractor.Fn(ec); len(ids) > 0 {
resolved.sourceIDs = ids
}
}
if resolved.targetExtractor.IsPhase(phase) {
if ids, _ := resolved.targetExtractor.Fn(ec); len(ids) > 0 {
resolved.targetIDs = ids
}
}
}
func (resolved *resolvedResourceWithTarget) Verb() Verb {
return resolved.verb
}
func (resolved *resolvedResourceWithTarget) Category() ActionCategory {
return resolved.category
}
func (resolved *resolvedResourceWithTarget) SourceResource() Resource {
return resolved.sourceResource
}
// An empty id (when none resolved) means collection-level access.
func (resolved *resolvedResourceWithTarget) SourceIDs() []string {
if len(resolved.sourceIDs) == 0 {
return []string{""}
}
return resolved.sourceIDs
}
func (resolved *resolvedResourceWithTarget) SourceSelector() SelectorFunc {
return resolved.sourceSelector
}
func (resolved *resolvedResourceWithTarget) TargetResource() Resource {
return resolved.targetResource
}
func (resolved *resolvedResourceWithTarget) TargetIDs() []string {
if len(resolved.targetIDs) == 0 {
return []string{""}
}
return resolved.targetIDs
}
func (resolved *resolvedResourceWithTarget) TargetSelector() SelectorFunc {
return resolved.targetSelector
}
func (resolved *resolvedResourceWithTarget) IsParentChild() bool {
return resolved.parentChild
}
func (resolved *resolvedResourceWithTarget) ResolveResponse(ec ExtractorContext) {
resolved.fill(PhaseResponse, ec)
}
func (resolved *resolvedResourceWithTarget) hasResponsePhase() bool {
return resolved.sourceExtractor.IsPhase(PhaseResponse) || resolved.targetExtractor.IsPhase(PhaseResponse)
}

View File

@@ -1,15 +1,48 @@
package coretypes
import "encoding/json"
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
WildCardSelectorString string = "*"
)
var errCodeInvalidResourceID = errors.MustNewCode("invalid_resource_id")
var WildcardSelector SelectorFunc = func(_ context.Context, resource Resource, _ string, _ valuer.UUID) ([]Selector, error) {
return []Selector{resource.Type().MustSelector(WildCardSelectorString)}, nil
}
var IDSelector SelectorFunc = func(_ context.Context, resource Resource, id string, _ valuer.UUID) ([]Selector, error) {
if id == "" {
return nil, errors.Newf(
errors.TypeInvalidInput,
errCodeInvalidResourceID,
"resource id is required for %s",
resource.Kind().String(),
)
}
selector, err := resource.Type().Selector(id)
if err != nil {
return nil, err
}
return []Selector{selector, resource.Type().MustSelector(WildCardSelectorString)}, nil
}
type Selector struct {
val string
}
// SelectorFunc maps a resolved id (+ its resource) to authz FGA selectors.
type SelectorFunc func(ctx context.Context, resource Resource, id string, orgID valuer.UUID) ([]Selector, error)
func (selector *Selector) MarshalJSON() ([]byte, error) {
return json.Marshal(selector.val)
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
)
const (
@@ -110,7 +109,7 @@ type listedDashboardV2 struct {
}
type listedDashboardV2Spec struct {
Display *common.Display `json:"display,omitempty"`
Display Display `json:"display,omitempty"`
}
func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {

View File

@@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
"k8s.io/apimachinery/pkg/util/validation"
)
@@ -62,10 +61,17 @@ type DashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d *DashboardV2) CanUpdate() error {
func (d *DashboardV2) ErrIfNotMutable() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
return nil
}
func (d *DashboardV2) ErrIfNotUpdatable() error {
if err := d.ErrIfNotMutable(); err != nil {
return err
}
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
@@ -73,7 +79,7 @@ func (d *DashboardV2) CanUpdate() error {
}
func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
if err := d.ErrIfNotUpdatable(); err != nil {
return err
}
if updatable.Name != d.Name {
@@ -87,7 +93,7 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
return nil
}
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
func (d *DashboardV2) ErrIfNotLockable(isAdmin bool, updatedBy string) error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
}
@@ -101,7 +107,7 @@ func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
if err := d.ErrIfNotLockable(isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
@@ -110,7 +116,7 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) CanDelete() error {
func (d *DashboardV2) ErrIfNotDeletable() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
@@ -168,9 +174,6 @@ func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PostableDashboardV2(tmp)
if p.Spec.Display == nil {
p.Spec.Display = &common.Display{}
}
if !p.GenerateName && p.Spec.Display.Name == "" {
p.Spec.Display.Name = p.Name
}
@@ -197,7 +200,7 @@ func (p *PostableDashboardV2) validateName() error {
if p.Name != "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name must be empty when generateName is true, got %q", p.Name)
}
if p.Spec.Display == nil || p.Spec.Display.Name == "" {
if p.Spec.Display.Name == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.display.name is required when generateName is true")
}
return nil
@@ -341,9 +344,6 @@ func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*u = UpdatableDashboardV2(tmp)
if u.Spec.Display == nil {
u.Spec.Display = &common.Display{}
}
if u.Spec.Display.Name == "" {
u.Spec.Display.Name = u.Name
}

View File

@@ -8,10 +8,9 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -166,7 +165,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
DashboardV2MetadataBase: DashboardV2MetadataBase{SchemaVersion: SchemaVersion},
GenerateName: true,
Spec: DashboardSpec{
Display: &common.Display{Name: "My Dashboard!"},
Display: Display{Name: "My Dashboard!"},
},
}

View File

@@ -17,12 +17,12 @@ import (
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
type DashboardSpec struct {
Display *common.Display `json:"display,omitempty"`
Display Display `json:"display" required:"true"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
Variables []Variable `json:"variables,omitempty"`
Panels map[string]*Panel `json:"panels"`
Layouts []Layout `json:"layouts"`
Duration common.DurationString `json:"duration"`
Variables []Variable `json:"variables" required:"true" nullable:"false"`
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration,omitempty"`
RefreshInterval common.DurationString `json:"refreshInterval,omitempty"`
Links []dashboard.Link `json:"links,omitempty"`
}

View File

@@ -18,8 +18,8 @@ import (
// ══════════════════════════════════════════════
type PanelPlugin struct {
Kind PanelPluginKind `json:"kind"`
Spec any `json:"spec"`
Kind PanelPluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
// PrepareJSONSchema marks the envelope with x-signoz-discriminator;
@@ -81,8 +81,8 @@ func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ══════════════════════════════════════════════
type QueryPlugin struct {
Kind QueryPluginKind `json:"kind"`
Spec any `json:"spec"`
Kind QueryPluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -139,8 +139,8 @@ func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ══════════════════════════════════════════════
type VariablePlugin struct {
Kind VariablePluginKind `json:"kind"`
Spec any `json:"spec"`
Kind VariablePluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -191,8 +191,8 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
// ══════════════════════════════════════════════
type DatasourcePlugin struct {
Kind DatasourcePluginKind `json:"kind"`
Spec any `json:"spec"`
Kind DatasourcePluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {

View File

@@ -13,6 +13,11 @@ import (
"github.com/swaggest/jsonschema-go"
)
type Display struct {
Name string `json:"name" required:"true"`
Description string `json:"description,omitempty"`
}
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
@@ -28,8 +33,8 @@ type DatasourceSpec struct {
// ══════════════════════════════════════════════
type Panel struct {
Kind PanelKind `json:"kind"`
Spec PanelSpec `json:"spec"`
Kind PanelKind `json:"kind" required:"true"`
Spec PanelSpec `json:"spec" required:"true"`
}
// PanelKind is the panel envelope discriminator. Perses leaves it a free
@@ -54,10 +59,10 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
}
type PanelSpec struct {
Display *dashboard.PanelDisplay `json:"display,omitempty"`
Plugin PanelPlugin `json:"plugin"`
Queries []Query `json:"queries,omitempty"`
Links []dashboard.Link `json:"links,omitempty"`
Display Display `json:"display" required:"true"`
Plugin PanelPlugin `json:"plugin" required:"true"`
Queries []Query `json:"queries" required:"true"`
Links []dashboard.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
@@ -65,13 +70,13 @@ type PanelSpec struct {
// ══════════════════════════════════════════════
type Query struct {
Kind qb.RequestType `json:"kind"`
Spec QuerySpec `json:"spec"`
Kind qb.RequestType `json:"kind" required:"true"`
Spec QuerySpec `json:"spec" required:"true"`
}
type QuerySpec struct {
Name string `json:"name,omitempty"`
Plugin QueryPlugin `json:"plugin"`
Plugin QueryPlugin `json:"plugin" required:"true"`
}
// ══════════════════════════════════════════════
@@ -82,8 +87,8 @@ type QuerySpec struct {
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind"`
Spec any `json:"spec"`
Kind variable.Kind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -138,7 +143,7 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
type ListVariableSpec struct {
Display *variable.Display `json:"display,omitempty"`
Display Display `json:"display" required:"true"`
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
AllowAllValue bool `json:"allowAllValue"`
AllowMultiple bool `json:"allowMultiple"`
@@ -158,8 +163,8 @@ type ListVariableSpec struct {
// based on Kind. No plugin is involved, so we reuse the Perses spec types as
// leaf imports.
type Layout struct {
Kind dashboard.LayoutKind `json:"kind"`
Spec any `json:"spec"`
Kind dashboard.LayoutKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
// layoutSpecs is the layout sum type factory. Perses only defines

View File

@@ -46,9 +46,9 @@ type Store interface {
// Returns ErrCodePinnedDashboardLimitHit when the user is at MaxPinnedDashboardsPerUser.
PinForUser(ctx context.Context, preference *UserDashboardPreference) error
UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error
UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error
DeletePreferencesForDashboard(ctx context.Context, dashboardID valuer.UUID) error
DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
}

View File

@@ -1,7 +1,10 @@
package dashboardtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -14,15 +17,20 @@ var ErrCodePinnedDashboardLimitHit = errors.MustNewCode("pinned_dashboard_limit_
type UserDashboardPreference struct {
bun.BaseModel `bun:"table:user_dashboard_preference,alias:user_dashboard_preference"`
UserID valuer.UUID `bun:"user_id,pk,type:text"`
DashboardID valuer.UUID `bun:"dashboard_id,pk,type:text"`
types.Identifiable
types.TimeAuditable
UserID valuer.UUID `bun:"user_id,type:text"`
DashboardID valuer.UUID `bun:"dashboard_id,type:text"`
IsPinned bool `bun:"is_pinned,notnull,default:false"`
}
func NewUserDashboardPreference(userID, dashboardID valuer.UUID) *UserDashboardPreference {
now := time.Now()
return &UserDashboardPreference{
UserID: userID,
DashboardID: dashboardID,
IsPinned: true,
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserID: userID,
DashboardID: dashboardID,
IsPinned: true,
}
}

View File

@@ -371,11 +371,10 @@ def idp_login(driver: webdriver.Chrome) -> Callable[[str, str], None]:
# Click the login button
login_button = wait.until(EC.element_to_be_clickable((By.ID, "kc-login")))
current_url = driver.current_url
login_button.click()
# Wait till the page redirects away from the login form. We poll the URL.
wait.until(EC.url_changes(current_url))
# Wait till kc-login element has vanished from the page, which means that a redirection is taking place.
wait.until(EC.invisibility_of_element((By.ID, "kc-login")))
return _idp_login
@@ -687,8 +686,6 @@ def perform_saml_login(
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
driver.get(url)
idp_login(email, password)
# wait until the browser lands back on SigNoz
WebDriverWait(driver, 15).until(lambda d: d.current_url.startswith(signoz.self.host_configs["8080"].base()))
def delete_keycloak_client(idp: types.TestContainerIDP, client_id: str) -> None:

View File

@@ -53,10 +53,10 @@ def test_create_auth_domain(
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
json={
"name": "oidc.basepath.test",
"ssoEnabled": True,
"provider": {
"type": "oidc",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
# Change the hostname of the issuer to the internal resolvable hostname of the idp

View File

@@ -51,10 +51,10 @@ def test_create_auth_domain(
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
json={
"name": "saml.basepath.test",
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],

View File

@@ -31,10 +31,10 @@ def test_create_and_get_domain(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "domain-google.integration.test",
"ssoEnabled": True,
"provider": {
"type": "google_auth",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "google_auth",
"googleAuthConfig": {
"clientId": "client-id",
"clientSecret": "client-secret",
"redirectURI": "redirect-uri",
@@ -52,10 +52,10 @@ def test_create_and_get_domain(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "domain-saml.integration.test",
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
@@ -86,7 +86,7 @@ def test_create_and_get_domain(
"domain-google.integration.test",
"domain-saml.integration.test",
]
assert domain["provider"]["type"] in ["google_auth", "saml"]
assert domain["config"]["ssoType"] in ["google_auth", "saml"]
def test_create_invalid(
@@ -96,16 +96,15 @@ def test_create_invalid(
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a domain with type saml but an oidc-shaped config; this should fail
# because the config is decoded as SAML and fails SAML validation.
# Create a domain with type saml and body for oidc, this should fail because oidcConfig is not allowed for saml
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "domain.integration.test",
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"oidcConfig": {
"clientId": "client-id",
"clientSecret": "client-secret",
"issuer": "issuer",
@@ -123,10 +122,10 @@ def test_create_invalid(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "$%^invalid",
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
@@ -143,15 +142,15 @@ def test_create_invalid(
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
},
},
}
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
@@ -159,7 +158,7 @@ def test_create_invalid(
assert response.status_code == HTTPStatus.BAD_REQUEST
# Create a domain with no provider
# Create a domain with no config
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
@@ -185,17 +184,17 @@ def test_create_invalid_role_mapping(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "invalid-role-test.integration.test",
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
},
},
"roleMapping": {
"defaultRole": "SUPERADMIN", # Invalid role
"roleMapping": {
"defaultRole": "SUPERADMIN", # Invalid role
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
@@ -209,19 +208,19 @@ def test_create_invalid_role_mapping(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "invalid-group-role.integration.test",
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"admins": "SUPERUSER", # Invalid role
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"admins": "SUPERUSER", # Invalid role
},
},
},
},
@@ -236,20 +235,20 @@ def test_create_invalid_role_mapping(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "valid-role-mapping.integration.test",
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": "saml-entity",
"samlIdp": "saml-idp",
"samlCert": "saml-cert",
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
},
},
},

View File

@@ -53,10 +53,10 @@ def test_create_auth_domain(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "saml.integration.test",
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
@@ -176,10 +176,10 @@ def test_saml_update_domain_with_group_mappings(
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
json={
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
@@ -189,15 +189,15 @@ def test_saml_update_domain_with_group_mappings(
"role": "signoz_role",
},
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
},
"useRoleAttribute": False,
},
"useRoleAttribute": False,
},
},
headers={"Authorization": f"Bearer {admin_token}"},
@@ -340,10 +340,10 @@ def test_saml_update_domain_with_use_role_claim(
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
json={
"ssoEnabled": True,
"provider": {
"type": "saml",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
@@ -353,14 +353,14 @@ def test_saml_update_domain_with_use_role_claim(
"role": "signoz_role",
},
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
"useRoleAttribute": True,
},
"useRoleAttribute": True,
},
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -57,10 +57,10 @@ def test_create_auth_domain(
signoz.self.host_configs["8080"].get("/api/v1/domains"),
json={
"name": "oidc.integration.test",
"ssoEnabled": True,
"provider": {
"type": "oidc",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
# Change the hostname of the issuer to the internal resolvable hostname of the idp
@@ -132,10 +132,10 @@ def test_oidc_update_domain_with_group_mappings(
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
json={
"ssoEnabled": True,
"provider": {
"type": "oidc",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
@@ -148,15 +148,15 @@ def test_oidc_update_domain_with_group_mappings(
"role": "signoz_role",
},
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"signoz-viewers": "VIEWER",
},
"useRoleAttribute": False,
},
"useRoleAttribute": False,
},
},
headers={"Authorization": f"Bearer {admin_token}"},
@@ -301,10 +301,10 @@ def test_oidc_update_domain_with_use_role_claim(
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
json={
"ssoEnabled": True,
"provider": {
"type": "oidc",
"config": {
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
@@ -317,14 +317,14 @@ def test_oidc_update_domain_with_use_role_claim(
"role": "signoz_role",
},
},
},
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
"roleMapping": {
"defaultRole": "VIEWER",
"groupMappings": {
"signoz-admins": "ADMIN",
"signoz-editors": "EDITOR",
},
"useRoleAttribute": True,
},
"useRoleAttribute": True,
},
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -1,5 +1,5 @@
import uuid
from collections.abc import Callable, Iterator
from collections.abc import Callable
from http import HTTPStatus
import pytest
@@ -8,96 +8,7 @@ import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
# The v2 dashboard API. Request shape (current):
# {"schemaVersion": "v6", "name": "<dns-1123-label>",
# "spec": {"display": {"name": "<human name>"}},
# "tags": [{"key": "...", "value": "..."}]}
# `name` is a DNS-1123 label identifier and is immutable after create;
# `spec.display.name` is the human-facing title used for name-sort/name-filter.
_BASE = "/api/v2/dashboards"
_TIMEOUT = 5
# This file's tests tag their dashboards with a `suite` marker so list queries
# can be scoped server-side. Each test gets its own unique marker (the
# suite_marker fixture) so tests stay isolated from each other and from leftovers
# in the reused session DB.
_SUITE_PREFIX = "dashboardv2"
def _headers(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _url(signoz: SigNoz, path: str = "") -> str:
return signoz.self.host_configs["8080"].get(f"{_BASE}{path}")
def _create(signoz: SigNoz, token: str, body: dict) -> requests.Response:
return requests.post(_url(signoz), json=body, headers=_headers(token), timeout=_TIMEOUT)
def _get(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
return requests.get(_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT)
# The tests exercise the per-user list (carries pin state); the pure list lives
# at GET /api/v2/dashboards.
def _list(signoz: SigNoz, token: str, **params: object) -> requests.Response:
url = signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards")
return requests.get(
url,
params={k: v for k, v in params.items() if v is not None},
headers=_headers(token),
timeout=_TIMEOUT,
)
# The pure, user-independent list — no pin join, no pinned field.
def _list_pure(signoz: SigNoz, token: str, **params: object) -> requests.Response:
return requests.get(
_url(signoz),
params={k: v for k, v in params.items() if v is not None},
headers=_headers(token),
timeout=_TIMEOUT,
)
def _update(signoz: SigNoz, token: str, dashboard_id: str, body: dict) -> requests.Response:
return requests.put(
_url(signoz, f"/{dashboard_id}"),
json=body,
headers=_headers(token),
timeout=_TIMEOUT,
)
def _delete(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
return requests.delete(_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT)
def _lock(signoz: SigNoz, token: str, dashboard_id: str, lock: bool) -> requests.Response:
method = requests.put if lock else requests.delete
return method(
_url(signoz, f"/{dashboard_id}/lock"),
headers=_headers(token),
timeout=_TIMEOUT,
)
def _pin(signoz: SigNoz, token: str, dashboard_id: str, pin: bool) -> requests.Response:
method = requests.put if pin else requests.delete
url = signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{dashboard_id}/pins")
return method(url, headers=_headers(token), timeout=_TIMEOUT)
def _minimal_body(name: str, display: str, tags: list[dict] | None = None) -> dict:
return {
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"tags": tags or [],
}
BASE_URL = "/api/v2/dashboards"
# ─── failure cases (create no dashboards) ────────────────────────────────────
@@ -110,7 +21,12 @@ def test_create_rejects_wrong_schema_version(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(signoz, token, {})
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
@@ -126,7 +42,12 @@ def test_create_rejects_missing_name(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(signoz, token, {"schemaVersion": "v6"})
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
@@ -141,7 +62,17 @@ def test_create_rejects_non_dns_name(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(signoz, token, _minimal_body(name="Not A Label", display="Not A Label"))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "Not A Label",
"spec": {"display": {"name": "Not A Label"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@@ -154,9 +85,18 @@ def test_create_rejects_unknown_field(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _minimal_body("rejects-unknown", "Rejects Unknown")
body["unknownfield"] = "boom"
response = _create(signoz, token, body)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-unknown",
"spec": {"display": {"name": "Rejects Unknown"}},
"tags": [],
"unknownfield": "boom",
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@@ -170,8 +110,17 @@ def test_create_rejects_reserved_tag_key(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _minimal_body("rejects-reserved", "Rejects Reserved", [{"key": "source", "value": "x"}])
response = _create(signoz, token, body)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-reserved",
"spec": {"display": {"name": "Rejects Reserved"}},
"tags": [{"key": "source", "value": "x"}],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@@ -185,7 +134,17 @@ def test_create_rejects_too_many_tags(
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
tags = [{"key": f"k{i}", "value": "v"} for i in range(11)]
response = _create(signoz, token, _minimal_body("too-many-tags", "Too Many", tags))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "too-many-tags",
"spec": {"display": {"name": "Too Many"}},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@@ -208,7 +167,12 @@ def test_list_rejects_invalid_params(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _list(signoz, token, **params)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_list_invalid"
@@ -221,7 +185,11 @@ def test_get_rejects_malformed_id(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _get(signoz, token, "not-a-uuid")
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@@ -233,7 +201,11 @@ def test_get_missing_dashboard_returns_not_found(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _get(signoz, token, str(uuid.uuid4()))
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@@ -245,7 +217,11 @@ def test_delete_missing_dashboard_returns_not_found(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _delete(signoz, token, str(uuid.uuid4()))
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@@ -257,58 +233,44 @@ def test_pin_missing_dashboard_returns_not_found(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _pin(signoz, token, str(uuid.uuid4()), pin=True)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{uuid.uuid4()}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ─── lifecycle ───────────────────────────────────────────────────────────────
# A single end-to-end flow through create → get → list/filter/sort → pin →
# update → lock → delete. Every fixture dashboard carries the shared suite marker
# tag so list queries can be scoped server-side, isolating this test from any
# other dashboards sharing the session DB.
def _display_names(body: dict) -> list[str]:
return [d["spec"]["display"]["name"] for d in body["data"]["dashboards"]]
def _delete_suite(signoz: SigNoz, token: str, suite_filter: str) -> None:
response = _list(signoz, token, query=suite_filter, limit=200)
if response.status_code != HTTPStatus.OK:
return
for dashboard in response.json()["data"]["dashboards"]:
_delete(signoz, token, dashboard["id"])
@pytest.fixture(name="suite_marker")
def _suite_marker(
signoz: SigNoz,
get_token: Callable[[str, str], str],
) -> Iterator[tuple[dict, str]]:
"""Yields a per-test unique suite (tag, filter) and deletes its dashboards on teardown.
Unique per test so the tests stay isolated from each other and from reused-DB leftovers."""
value = f"{_SUITE_PREFIX}-{uuid.uuid4().hex[:8]}"
suite_tag = {"key": "suite", "value": value}
suite_filter = f"suite = '{value}'"
yield suite_tag, suite_filter
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_delete_suite(signoz, token, suite_filter)
# update → lock → delete.
def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-statements
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
suite_marker: tuple[dict, str],
):
suite_tag, suite_filter = suite_marker
def _scoped(query: str) -> str:
return f"({query}) AND {suite_filter}"
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# The dashboard test files share this package's DB and it's reused across
# runs, so start from a clean slate: delete every dashboard (which also clears
# pins via the delete cascade). This test then owns the whole dashboard space
# and asserts on global counts.
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
dashboard_requests = [
(
"lc-alpha",
@@ -353,18 +315,38 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
# ── stage 1: create ──────────────────────────────────────────────────────
ids: dict[str, str] = {}
for name, display, tags in dashboard_requests:
response = _create(signoz, token, _minimal_body(name, display, [suite_tag, *tags]))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
ids[name] = response.json()["data"]["id"]
# TODO: re-enable once the dashboard name unique index lands — creating a
# second dashboard with an existing name should conflict (409). Until the
# index exists, duplicate names are silently allowed.
# response = _create(signoz, token, _minimal_body("lc-alpha", "Alpha Dupe"))
# response = requests.post(
# signoz.self.host_configs["8080"].get(_BASE),
# json={"schemaVersion": "v6", "name": "lc-alpha",
# "spec": {"display": {"name": "Alpha Dupe"}}, "tags": []},
# headers={"Authorization": f"Bearer {token}"},
# timeout=5,
# )
# assert response.status_code == HTTPStatus.CONFLICT, response.text
# ── stage 2: get one and verify the round-tripped shape ──────────────────
response = _get(signoz, token, ids["lc-alpha"])
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
alpha = response.json()["data"]
assert alpha["id"] == ids["lc-alpha"]
@@ -375,12 +357,17 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert alpha["locked"] is False
assert {"key": "team", "value": "pulse"} in alpha["tags"]
# ── stage 3: list everything in the suite ────────────────────────────────
response = _list(signoz, token, query=suite_filter, limit=200)
# ── stage 3: list everything ─────────────────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["data"]["total"] == 6
assert set(_display_names(body)) == {
assert {d["spec"]["display"]["name"] for d in body["data"]["dashboards"]} == {
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
@@ -490,13 +477,23 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
),
]
for query, expected in cases:
response = _list(signoz, token, query=_scoped(query), limit=200)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": query, "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert set(_display_names(response.json())) == expected, query
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
# ── stage 5: name sort honours order ─────────────────────────────────────
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
assert _display_names(response.json()) == [
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
"Alpha Overview",
"Beta Overview",
"Delta Storage",
@@ -504,8 +501,13 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Gamma Storage",
"Zeta Overview",
]
response = _list(signoz, token, query=suite_filter, sort="name", order="desc", limit=200)
assert _display_names(response.json()) == [
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "desc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
"Zeta Overview",
"Gamma Storage",
"Epsilon Metrics",
@@ -515,8 +517,20 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
]
# ── stage 6: pinning floats a dashboard to the top of any ordering ───────
assert _pin(signoz, token, ids["lc-gamma"], pin=True).status_code == HTTPStatus.NO_CONTENT
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
dashboards = response.json()["data"]["dashboards"]
assert dashboards[0]["name"] == "lc-gamma"
assert dashboards[0]["pinned"] is True
@@ -524,8 +538,13 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
# the pure list is user-independent: the same pin neither reorders it (gamma
# stays in natural name order, not floated to the top) nor adds a pinned field.
response = _list_pure(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
assert _display_names(response.json()) == [
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "name", "order": "asc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
"Alpha Overview",
"Beta Overview",
"Delta Storage",
@@ -536,9 +555,21 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert all("pinned" not in d for d in response.json()["data"]["dashboards"])
# ── stage 7: unpinning restores the natural ordering ─────────────────────
assert _pin(signoz, token, ids["lc-gamma"], pin=False).status_code == HTTPStatus.NO_CONTENT
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
assert _display_names(response.json()) == [
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
"Alpha Overview",
"Beta Overview",
"Delta Storage",
@@ -548,39 +579,95 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
]
# ── stage 8: update mutates the spec but keeps the immutable name ────────
update_body = _minimal_body(
"lc-alpha",
"Alpha Overview",
[
suite_tag,
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
"spec": {"display": {"name": "Alpha Overview"}},
"tags": [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
],
)
}
update_body["spec"]["display"]["description"] = "now with a description"
response = _update(signoz, token, ids["lc-alpha"], update_body)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
json=update_body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = _get(signoz, token, ids["lc-alpha"])
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.json()["data"]["spec"]["display"]["description"] == "now with a description"
# ── stage 9: a locked dashboard rejects updates until unlocked ───────────
assert _lock(signoz, token, ids["lc-beta"], lock=True).status_code == HTTPStatus.NO_CONTENT
beta_body = _minimal_body(
"lc-beta",
"Beta Overview",
[suite_tag, {"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
beta_body = {
"schemaVersion": "v6",
"name": "lc-beta",
"spec": {"display": {"name": "Beta Overview"}},
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
}
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}"),
json=beta_body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
response = _update(signoz, token, ids["lc-beta"], beta_body)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert _lock(signoz, token, ids["lc-beta"], lock=False).status_code == HTTPStatus.NO_CONTENT
assert _update(signoz, token, ids["lc-beta"], beta_body).status_code == HTTPStatus.OK
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}"),
json=beta_body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.OK
)
# ── stage 10: delete removes the dashboard from get and list ─────────────
assert _delete(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NO_CONTENT
assert _get(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NOT_FOUND
response = _list(signoz, token, query=suite_filter, limit=200)
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
assert (
requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NOT_FOUND
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.json()["data"]["total"] == 5
assert set(_display_names(response.json())) == {
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == {
"Alpha Overview",
"Beta Overview",
"Delta Storage",
@@ -593,34 +680,89 @@ def test_dashboard_v2_pin_limit(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
suite_marker: tuple[dict, str],
):
suite_tag, _ = suite_marker
max_pinned = 10
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Wipe the dashboard space (see lifecycle) so the per-user pin cap this test
# asserts against starts empty — deleting dashboards clears their pins.
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
ids: list[str] = []
for i in range(max_pinned + 1):
response = _create(signoz, token, _minimal_body(f"pl-{i}", f"Pin Limit {i}", [suite_tag]))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": f"pl-{i}",
"spec": {"display": {"name": f"Pin Limit {i}"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
ids.append(response.json()["data"]["id"])
# pinning up to the limit succeeds
for dashboard_id in ids[:max_pinned]:
assert _pin(signoz, token, dashboard_id, pin=True).status_code == HTTPStatus.NO_CONTENT
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{dashboard_id}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
# re-pinning an already-pinned dashboard is an idempotent no-op, even at the limit
assert _pin(signoz, token, ids[0], pin=True).status_code == HTTPStatus.NO_CONTENT
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[0]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
# the 11th distinct pin is rejected with the typed limit error
response = _pin(signoz, token, ids[max_pinned], pin=True)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[max_pinned]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
assert response.json()["error"]["code"] == "pinned_dashboard_limit_hit"
# unpinning frees a slot, so the previously-rejected dashboard can now be pinned
assert _pin(signoz, token, ids[0], pin=False).status_code == HTTPStatus.NO_CONTENT
assert _pin(signoz, token, ids[max_pinned], pin=True).status_code == HTTPStatus.NO_CONTENT
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[0]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[max_pinned]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
# ─── LIKE escaping ───────────────────────────────────────────────────────────
@@ -638,12 +780,24 @@ def test_dashboard_v2_like_escaping(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
suite_marker: tuple[dict, str],
):
suite_tag, suite_filter = suite_marker
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Wipe the dashboard space (see lifecycle) so the filter assertions run
# against only the dashboards this test creates.
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
dashboard_requests = [
("esc-pct", "Cost 50% Report"),
("esc-pct-plain", "Cost 5000 Report"),
@@ -651,7 +805,17 @@ def test_dashboard_v2_like_escaping(
("esc-underscore-wild", "userXid panel"),
]
for name, display in dashboard_requests:
response = _create(signoz, token, _minimal_body(name, display, [suite_tag]))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
cases = [
@@ -685,11 +849,11 @@ def test_dashboard_v2_like_escaping(
),
]
for query, expected in cases:
response = _list(
signoz,
token,
query=f"({query}) AND {suite_filter}",
limit=200,
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": query, "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert set(_display_names(response.json())) == expected, query
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query