Compare commits

...

3 Commits

Author SHA1 Message Date
Nikhil Mantri
8bc4c5f79c feat(authz): rule FGA (#13253)
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
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Move `/api/v2/rules*`, `/api/v3/rules` and `/api/v2/rules/test` from
role gates to `CheckResources` on rule verbs.
- Create, update, patch, clone and test also check attach and detach on
the notification channels the rule names, diffed against the stored
rule. Unknown channel names are rejected with 400.
- Create, update, patch, clone and test also check read on the telemetry
the rule's query touches, so a rule cannot be used to read signals the
caller cannot query directly. Managed roles already hold these reads,
only custom roles are affected.
- PATCH checks telemetry read only when the body carries
`condition.compositeQuery`, so disabling or renaming a rule needs no
read on its stored query. This adds an opt-in `Optional` flag on
`TelemetryResourceDef` for that one route, every other telemetry def
keeps failing closed.
- Detach of a channel name that no longer exists is skipped instead of
rejected, so a rule whose channel was removed can still be updated.
Attach stays strict.
- Integration tests cover viewer, editor and a custom role on every
gate. No migration, rule tuples come from #13251 and channel links from
#13249.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#286
Closes SigNoz/pulse-pod#355

#### Additional Information

Fifth of five stacked PRs splitting #12927. Stacked on #13252.
2026-11-13 09:15:38 +00:00
Naman Verma
198230f064 fix: remove algorithm from all rule examples and request builders (#13567)
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/732
2026-11-13 09:11:00 +00:00
Aswin K.M.
0791306e52 feat(build-staging): release staging through the deployments repo on merges and labels (#13571)
#### Description

- Merges to `main` now build and release the staging base
(`overlays/staging/*/signoz/base`), which every staging overlay
inherits.
- `staging:<deployment>` labels on PRs still release only that
deployment's overlay.
- Releases go to `signoz.staging.cl.gcp.deployments` with a `{path,
image, tag}` payload instead of `{deployment, signoz_version, source}`.
- The deployment label is checked against
`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` before it goes into the path glob and
the JSON payload.
- `checkout`, `create-github-app-token` and `cache` are pinned to
release SHAs.
2026-11-13 09:04:43 +00:00
19 changed files with 1466 additions and 181 deletions

View File

@@ -18,25 +18,25 @@ env:
jobs:
prepare:
runs-on: ubuntu-latest
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
if: ${{ github.event_name == 'push' || startsWith(github.event.label.name, 'staging:') }}
outputs:
version: ${{ steps.build-info.outputs.version }}
hash: ${{ steps.build-info.outputs.hash }}
time: ${{ steps.build-info.outputs.time }}
branch: ${{ steps.build-info.outputs.branch }}
deployment: ${{ steps.build-info.outputs.deployment }}
path: ${{ steps.build-info.outputs.path }}
steps:
- name: self-checkout
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: token
name: github-token-gen
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ secrets.PRIMUS_APP_ID }}
private-key: ${{ secrets.PRIMUS_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: primus-checkout
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: signoz/primus
ref: main
@@ -44,22 +44,29 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: build-info
id: build-info
env:
LABEL: ${{ github.event.label.name }}
run: |
echo "version=$($MAKE info-version)" >> $GITHUB_OUTPUT
echo "hash=$($MAKE info-commit-short)" >> $GITHUB_OUTPUT
echo "time=$($MAKE info-timestamp)" >> $GITHUB_OUTPUT
echo "branch=$($MAKE info-branch)" >> $GITHUB_OUTPUT
staging_label="${{ github.event.label.name }}"
if [[ "${staging_label}" == "staging:"* ]]; then
deployment=${staging_label#"staging:"}
elif [[ "${{ github.event.ref }}" == "refs/heads/main" ]]; then
deployment="staging"
# merges to main release the base every overlay inherits, a staging:<deployment> label releases that overlay
if [[ "${GITHUB_EVENT_NAME}" == push ]]; then
path="overlays/staging/*/signoz/base"
else
echo "error: not able to determine deployment - please verify the PR label or the branch"
# the deployment is spliced into the release path glob and the JSON payload
deployment="${LABEL#staging:}"
if [[ ! "${deployment}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
echo "::error::label ${LABEL} must be staging:<deployment>, where the deployment is lowercase letters, digits and dashes"
exit 1
fi
path="overlays/staging/*/signoz/overlays/${deployment}"
fi
echo "deployment=${deployment}" >> $GITHUB_OUTPUT
{
echo "version=$($MAKE info-version)"
echo "hash=$($MAKE info-commit-short)"
echo "time=$($MAKE info-timestamp)"
echo "branch=$($MAKE info-branch)"
echo "path=${path}"
} >> "${GITHUB_OUTPUT}"
- name: create-dotenv
run: |
mkdir -p frontend
@@ -71,7 +78,7 @@ jobs:
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: frontend/.env
key: staging-dotenv-${{ github.sha }}
@@ -115,7 +122,6 @@ jobs:
DOCKER_MANIFEST: true
DOCKER_PROVIDERS: gcp
staging:
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
uses: signoz/primus.workflows/.github/workflows/github-trigger.yaml@main
secrets: inherit
needs: [prepare, go-build]
@@ -123,6 +129,6 @@ jobs:
PRIMUS_REF: main
GITHUB_ENVIRONMENT: staging
GITHUB_SILENT: true
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
GITHUB_EVENT_NAME: releaser
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'
GITHUB_REPOSITORY_NAME: signoz.staging.cl.gcp.deployments
GITHUB_EVENT_NAME: release
GITHUB_EVENT_PAYLOAD: '{"path": "${{ needs.prepare.outputs.path }}", "image": "us-central1-docker.pkg.dev/signoz-registry/signoz/signoz", "tag": "${{ needs.prepare.outputs.version }}"}'

View File

@@ -25613,7 +25613,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -26280,9 +26279,23 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Create alert rule
tags:
- rules
@@ -26327,9 +26340,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:delete
- tokenizer:
- EDITOR
- rule:delete
summary: Delete alert rule
tags:
- rules
@@ -26385,9 +26398,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get alert rule by ID
tags:
- rules
@@ -26569,7 +26582,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -27242,9 +27254,27 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Patch alert rule
tags:
- rules
@@ -27426,7 +27456,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -28087,9 +28116,27 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Update alert rule
tags:
- rules
@@ -28156,9 +28203,25 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:read
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:read
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Clone alert rule
tags:
- rules
@@ -29045,7 +29108,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -29712,9 +29774,23 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Test alert rule
tags:
- rules
@@ -31809,9 +31885,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: List alert rules (v3)
tags:
- rules

View File

@@ -630,7 +630,6 @@ describe('Footer utils', () => {
expect(props.ruleType).toBe('anomaly_rule');
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.condition.algorithm).toBe('standard');
expect(props.condition.seasonality).toBe('daily');
expect(props.condition.thresholds?.spec[0]).toMatchObject({
target: 3,

View File

@@ -324,7 +324,6 @@ export function buildCreateAnomalyAlertRulePayload(
condition: {
...payload.condition,
thresholds: { kind: 'basic', spec: thresholds },
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
},
};

View File

@@ -323,7 +323,6 @@ describe('CreateAlert utils', () => {
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'weekly',
thresholds: {
kind: 'basic',
@@ -349,7 +348,6 @@ describe('CreateAlert utils', () => {
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
operator: AlertThresholdOperator.IS_BELOW,
thresholds: [{ thresholdValue: 3 }],
algorithm: 'standard',
seasonality: 'weekly',
});
});

View File

@@ -274,8 +274,6 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
};

View File

@@ -36,7 +36,6 @@ export interface PostableAlertRuleV2 {
absentFor?: number;
requireMinPoints?: boolean;
requiredNumPoints?: number;
algorithm?: string;
seasonality?: string;
};
evaluation?: {

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -14,124 +14,343 @@ import (
)
func (provider *provider) addRulerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v3/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRulesV3, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetRuleByID), handler.OpenAPIDef{
ID: "GetRuleByID",
Tags: []string{"rules"},
Summary: "Get alert rule by ID",
Description: "This endpoint returns an alert rule by ID",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleByID",
Tags: []string{"rules"},
Summary: "Get alert rule by ID",
Description: "This endpoint returns an alert rule by ID",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateRule), handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CloneRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCloneRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewReplaceRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewReplaceRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.PatchRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewPatchRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewPatchRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewOptionalRuleBodyTelemetryResources(),
Optional: true,
},
),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.PatchRuleByID), handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/test", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.TestRule), handler.OpenAPIDef{
ID: "TestRule",
Tags: []string{"rules"},
Summary: "Test alert rule",
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/test", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.TestRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "TestRule",
Tags: []string{"rules"},
Summary: "Test alert rule",
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}

View File

@@ -251,7 +251,6 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
},
},
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,

View File

@@ -5,28 +5,23 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPostableRuleExamplesValidate verifies every example payload returned by
// postableRuleExamples() round-trips through PostableRule.UnmarshalJSON and
// postableRuleExamples() round-trips through PostableRule.DecodeStrict and
// passes Validate(). If an example drifts from the runtime contract this
// breaks loudly so the spec doesn't ship invalid payloads to users.
func TestPostableRuleExamplesValidate(t *testing.T) {
for _, example := range postableRuleExamples() {
t.Run(example.Name, func(t *testing.T) {
raw, err := json.Marshal(example.Value)
if err != nil {
t.Fatalf("marshal example: %v", err)
}
require.NoError(t, err)
var rule ruletypes.PostableRule
if err := json.Unmarshal(raw, &rule); err != nil {
t.Fatalf("unmarshal: %v\npayload: %s", err, raw)
}
if err := rule.Validate(); err != nil {
t.Fatalf("Validate: %v\npayload: %s", err, raw)
}
require.NoError(t, rule.DecodeStrict(string(raw)), "payload: %s", raw)
assert.NoError(t, rule.Validate(), "payload: %s", raw)
})
}
}

View File

@@ -109,6 +109,8 @@ type TelemetryResourceDef struct {
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
// Optional skips the check instead of failing when the request carries no telemetry resources.
Optional bool
}
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
@@ -116,6 +118,9 @@ func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []
if err != nil {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
}
if len(refs) == 0 && def.Optional {
return nil
}
if len(refs) == 0 {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
def.Verb,

View File

@@ -69,3 +69,43 @@ func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
}
func TestTelemetryResourceDefWithoutResources(t *testing.T) {
testCases := []struct {
name string
optional bool
wantLen int
wantErr bool
}{
{
name: "Required_Errors",
optional: false,
wantLen: 1,
wantErr: true,
},
{
name: "Optional_ResolvesNothing",
optional: true,
wantLen: 0,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
def := TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
Resources: func(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) { return nil, nil },
Optional: testCase.optional,
}
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{})
require.Len(t, resolved, testCase.wantLen)
if testCase.wantErr {
assert.Error(t, resolved[0].Err())
}
})
}
}

View File

@@ -1054,31 +1054,24 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
return nil, err
}
storedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
// a rule stored before selectedQueryName was required resolves it the way evaluation does
storedRule.RuleCondition.SelectedQuery = storedRule.RuleCondition.SelectedQueryName()
if err := storedRule.DecodeStrict(ruleStr); err != nil {
storedRule, err := ruletypes.NewPatchedPostableRule([]byte(storedJSON.Data), []byte(ruleStr))
if err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
if err := storedRule.Validate(); err != nil {
return nil, err
}
if err := m.validateChannels(ctx, claims.OrgID, &storedRule); err != nil {
if err := m.validateChannels(ctx, claims.OrgID, storedRule); err != nil {
return nil, err
}
// deploy or un-deploy task according to patched (new) rule state
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
return nil, err
}
newStoredJson, err := json.Marshal(&storedRule)
newStoredJson, err := json.Marshal(storedRule)
if err != nil {
m.logger.ErrorContext(ctx, "failed to marshal new stored rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
@@ -1091,7 +1084,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil })
if err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to restore rule after patch failure", "task_name", taskName, errors.Attr(err))
}
return nil, err
@@ -1100,7 +1093,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// prepare http response
response := ruletypes.GettableRule{
Id: id.StringValue(),
PostableRule: storedRule,
PostableRule: *storedRule,
}
// fetch state of rule from memory

View File

@@ -5,12 +5,15 @@ import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
)
const (
@@ -20,6 +23,8 @@ const (
type diffSide int
type incomingChannelsFunc func(stored *ruletypes.GettableRule, body []byte) ([]string, error)
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
if err != nil {
@@ -81,6 +86,126 @@ func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.Resou
}}
}
func NewCreateRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
names, err := parseRuleChannels(ec.RequestBody)
if err != nil {
return nil, err
}
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), names)
}}
}
func NewCloneRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), stored.Channels())
}}
}
func NewReplaceRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, attachedSide)
}
func NewReplaceRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, detachedSide)
}
func NewPatchRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, attachedSide)
}
func NewPatchRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, detachedSide)
}
func newRuleChannelDiffExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler, incoming incomingChannelsFunc, side diffSide) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}
incomingNames, err := incoming(stored, ec.RequestBody)
if err != nil {
return nil, err
}
attached, detached := diffLinks(stored.Channels(), incomingNames)
if side == detachedSide {
return resolveStoredChannelIDs(ctx, alertmanagerService, orgID, detached)
}
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), attached)
}}
}
func parseReplacedRuleChannels(_ *ruletypes.GettableRule, body []byte) ([]string, error) {
return parseRuleChannels(body)
}
func parsePatchedRuleChannels(stored *ruletypes.GettableRule, body []byte) ([]string, error) {
patched, err := applyRulePatch(stored, body)
if err != nil {
return nil, err
}
return patched.Channels(), nil
}
func applyRulePatch(stored *ruletypes.GettableRule, body []byte) (*ruletypes.PostableRule, error) {
encoded, err := json.Marshal(stored.PostableRule)
if err != nil {
return nil, err
}
return ruletypes.NewPatchedPostableRule(encoded, body)
}
func parseRuleChannels(body []byte) ([]string, error) {
rule := new(ruletypes.PostableRule)
if err := json.Unmarshal(body, rule); err != nil {
return nil, err
}
return rule.Channels(), nil
}
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
return diffLinks(defaultToWildcardIfEmpty(stored), incoming)
}
@@ -153,6 +278,19 @@ func newRoutePolicyDiffExtractor(alertmanagerService alertmanager.Alertmanager,
}}
}
func resolveStoredChannelIDs(ctx context.Context, alertmanagerService alertmanager.Alertmanager, orgID valuer.UUID, names []string) ([]string, error) {
if len(names) == 0 {
return nil, nil
}
channels, err := alertmanagerService.ListChannels(ctx, orgID.StringValue())
if err != nil {
return nil, err
}
return alertmanagertypes.MapKnownChannelNamesToIDs(channels, names), nil
}
func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -184,17 +322,47 @@ func NewRuleQueryFieldResources(rulerService Ruler) coretypes.ResourceExtractor
}
}
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
if ec.Request == nil {
return nil, nil
}
func NewRuleBodyTelemetryResources() coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
compositeQuery := gjson.GetBytes(ec.RequestBody, "condition.compositeQuery")
if !compositeQuery.Exists() {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery is required")
}
body, err := json.Marshal(map[string]json.RawMessage{"compositeQuery": json.RawMessage(compositeQuery.Raw)})
if err != nil {
return nil, err
}
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
}
}
func NewOptionalRuleBodyTelemetryResources() coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
if !gjson.GetBytes(ec.RequestBody, "condition.compositeQuery").Exists() {
return nil, nil
}
return NewRuleBodyTelemetryResources()(ec)
}
}
func fetchStoredRule(ec coretypes.ExtractorContext, rulerService Ruler) (*ruletypes.GettableRule, error) {
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
stored, err := rulerService.GetRule(ec.Request.Context(), ruleID)
return rulerService.GetRule(ec.Request.Context(), ruleID)
}
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
if ec.Request == nil {
return nil, nil
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}

View File

@@ -1,14 +1,19 @@
package ruler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertest"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
@@ -314,3 +319,298 @@ func TestMapFieldResources(t *testing.T) {
})
}
}
func v2RuleJSON(channels string) string {
return `{
"alert": "TestAlert",
"version": "v5",
"schemaVersion": "v2alpha1",
"ruleType": "threshold_rule",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90.0, "matchType": "1", "op": "1", "channels": ` + channels + `}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {"evalWindow": "5m", "frequency": "1m"}
},
"notificationSettings": {
"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}
}
}`
}
func TestParseRuleChannels(t *testing.T) {
testCases := []struct {
name string
body string
wantChannels []string
wantErr bool
}{
{
name: "V2ThresholdChannels_ReadDirectly",
body: v2RuleJSON(`["email"]`),
wantChannels: []string{"email"},
},
{
name: "V2EmptyChannels_Empty",
body: v2RuleJSON(`[]`),
wantChannels: nil,
},
{
name: "NotJSON_Errors",
body: "not json",
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
channels, err := parseRuleChannels([]byte(testCase.body))
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantChannels, channels)
})
}
}
func TestIncomingRuleChannels(t *testing.T) {
testCases := []struct {
name string
incoming incomingChannelsFunc
stored string
body string
wantChannels []string
wantStoredAfter []string
wantErr bool
}{
{
name: "Replaced_IgnoresStored",
incoming: parseReplacedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`["pager"]`),
wantChannels: []string{"pager"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_OmitsCondition_StoredChannelsSurvive",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: `{"disabled": true}`,
wantChannels: []string{"slack"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_SetsChannels_StoredChannelsReplaced",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`["pager"]`),
wantChannels: []string{"pager"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_EmptiesChannels_NoChannelsLeft",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`[]`),
wantChannels: nil,
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_UnknownField_Errors",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: `{"preferredChannels": ["pager"]}`,
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stored := new(ruletypes.GettableRule)
require.NoError(t, json.Unmarshal([]byte(testCase.stored), &stored.PostableRule))
channels, err := testCase.incoming(stored, []byte(testCase.body))
if testCase.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantChannels, channels)
assert.Equal(t, testCase.wantStoredAfter, stored.Channels())
})
}
}
func TestRuleBodyTelemetryResources(t *testing.T) {
testCases := []struct {
name string
body string
wantKind coretypes.Kind
wantID string
wantErr bool
}{
{
name: "V5Body_NoFilter_MetricsWildcard",
body: v2RuleJSON(`[]`),
wantKind: coretypes.KindMetrics,
wantID: "builder_query/*",
},
{
name: "MissingCondition_Errors",
body: `{"alert": "TestAlert", "version": "v5"}`,
wantErr: true,
},
{
name: "NotJSON_Errors",
body: "not json",
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := NewRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.Len(t, refs, 1)
assert.Equal(t, testCase.wantKind, refs[0].Resource.Kind())
assert.Equal(t, testCase.wantID, refs[0].ID)
})
}
}
func TestApplyRulePatch(t *testing.T) {
logsQuery := `{"condition": {"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "stepInterval": "5m"}}]}}}`
testCases := []struct {
name string
body string
wantSignal string
wantChannels []string
}{
{
name: "BodyWithoutCondition_KeepsStoredQueryAndChannels",
body: `{"disabled": true}`,
wantSignal: "metrics",
wantChannels: []string{"slack"},
},
{
name: "BodyWithNewQuery_ReplacesSignalKeepsThresholds",
body: logsQuery,
wantSignal: "logs",
wantChannels: []string{"slack"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stored := new(ruletypes.GettableRule)
require.NoError(t, json.Unmarshal([]byte(v2RuleJSON(`["slack"]`)), &stored.PostableRule))
patched, err := applyRulePatch(stored, []byte(testCase.body))
require.NoError(t, err)
require.NotNil(t, patched.RuleCondition)
require.NotNil(t, patched.RuleCondition.CompositeQuery)
encoded, err := json.Marshal(patched.RuleCondition.CompositeQuery)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"signal":"`+testCase.wantSignal+`"`)
assert.Equal(t, testCase.wantChannels, patched.Channels())
})
}
}
func TestOptionalRuleBodyTelemetryResources(t *testing.T) {
testCases := []struct {
name string
body string
wantRefs int
}{
{
name: "BodyWithoutCondition_NoResources",
body: `{"disabled": true}`,
wantRefs: 0,
},
{
name: "BodyWithQuery_MetricsResource",
body: v2RuleJSON(`[]`),
wantRefs: 1,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := NewOptionalRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Len(t, refs, testCase.wantRefs)
})
}
}
func TestResolveStoredChannelIDs(t *testing.T) {
channels := []*alertmanagertypes.Channel{
{Identifiable: types.Identifiable{ID: valuer.GenerateUUID()}, Name: "slack", DisplayName: "slack"},
}
testCases := []struct {
name string
names []string
wantIDs []string
}{
{
name: "KnownName_Resolved",
names: []string{"slack"},
wantIDs: []string{channels[0].ID.StringValue()},
},
{
name: "UnknownName_Skipped",
names: []string{"slack", "gone"},
wantIDs: []string{channels[0].ID.StringValue()},
},
{
name: "NoNames_NoLookup",
names: nil,
wantIDs: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
alertmanagerService := alertmanagertest.NewMockAlertmanager(t)
if len(testCase.names) > 0 {
alertmanagerService.EXPECT().ListChannels(mock.Anything, mock.Anything).Return(channels, nil)
}
ids, err := resolveStoredChannelIDs(context.Background(), alertmanagerService, valuer.GenerateUUID(), testCase.names)
require.NoError(t, err)
assert.Equal(t, testCase.wantIDs, ids)
})
}
}

View File

@@ -168,6 +168,23 @@ func MapChannelNamesToIDs(channels Channels, names []string) ([]string, error) {
return ids, nil
}
// MapKnownChannelNamesToIDs skips names with no matching channel instead of failing.
func MapKnownChannelNamesToIDs(channels Channels, names []string) []string {
idByName := make(map[string]string, len(channels))
for _, channel := range channels {
idByName[channel.DisplayName] = channel.ID.StringValue()
}
ids := make([]string, 0, len(names))
for _, name := range names {
if id, ok := idByName[name]; ok {
ids = append(ids, id)
}
}
return ids
}
func NewStatsFromChannels(channels Channels) map[string]any {
stats := make(map[string]any)
for _, channel := range channels {

View File

@@ -332,3 +332,28 @@ func TestMapChannelNamesToIDs(t *testing.T) {
})
}
}
func TestMapKnownChannelNamesToIDs(t *testing.T) {
slackID := valuer.GenerateUUID()
pagerID := valuer.GenerateUUID()
channels := Channels{
{Identifiable: types.Identifiable{ID: slackID}, DisplayName: "slack-alerts"},
{Identifiable: types.Identifiable{ID: pagerID}, DisplayName: "pager-oncall"},
}
testCases := []struct {
name string
names []string
want []string
}{
{name: "AllKnown_IDsInRequestOrder", names: []string{"pager-oncall", "slack-alerts"}, want: []string{pagerID.StringValue(), slackID.StringValue()}},
{name: "UnknownName_Skipped", names: []string{"slack-alerts", "deleted-channel"}, want: []string{slackID.StringValue()}},
{name: "NoNames_Empty", names: nil, want: []string{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, MapKnownChannelNamesToIDs(channels, testCase.names))
})
}
}

View File

@@ -250,6 +250,21 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
return r.validate()
}
func NewPatchedPostableRule(stored []byte, patch []byte) (*PostableRule, error) {
patched := new(PostableRule)
if err := json.Unmarshal(stored, patched); err != nil {
return nil, err
}
// a rule stored before selectedQueryName was required resolves it the way evaluation does
patched.RuleCondition.SelectedQuery = patched.RuleCondition.SelectedQueryName()
if err := patched.DecodeStrict(string(patch)); err != nil {
return nil, err
}
return patched, nil
}
func (r *PostableRule) DecodeStrict(ruleStr string) error {
err := json.Unmarshal([]byte(ruleStr), r)
if err == nil {

View File

@@ -35,6 +35,9 @@ _DOWNTIME_NAME = "alert-authz-downtime"
_ACTOR_DOWNTIME_NAME = "alert-authz-actor-downtime"
_ROUTE_POLICY_NAME = "alert-authz-route-policy"
_ACTOR_ROUTE_POLICY_NAME = "alert-authz-actor-route-policy"
_EDITOR_RULE = "alert authz editor rule"
_ACTOR_RULE = "alert authz actor rule"
_ACTOR_SWAP_RULE = "alert authz actor swap rule"
SEED_CHANNEL = {"name": "alert-authz-channel", "email_configs": [{"to": "alert-authz@integration.test"}]}
SEED_CHANNEL_ALT = {"name": "alert-authz-channel-alt", "email_configs": [{"to": "alert-authz-alt@integration.test"}]}
@@ -95,6 +98,24 @@ _ACTOR_BASE_TRANSACTION_GROUPS = [
transaction_group("attach", "metaresource", "rule", ["*"]),
]
_ACTOR_RULE_TRANSACTION_GROUPS = _ACTOR_BASE_TRANSACTION_GROUPS + [
transaction_group("create", "metaresource", "rule", ["*"]),
transaction_group("read", "metaresource", "rule", ["*"]),
transaction_group("list", "metaresource", "rule", ["*"]),
transaction_group("update", "metaresource", "rule", ["*"]),
transaction_group("delete", "metaresource", "rule", ["*"]),
transaction_group("detach", "metaresource", "rule", ["*"]),
]
LOGS_RULE_CONDITION = {
**RULE_CONDITION,
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
},
}
def test_setup_users_and_channels(
signoz: SigNoz,
@@ -441,6 +462,148 @@ def test_viewer_allowed_on_rule_views(
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_viewer_allowed_on_rule_reads(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{target_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"GET rule {target_id}: expected 200, got {response.status_code}: {response.text}"
def test_viewer_forbidden_on_rule_writes(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
body = {**RULE_TEMPLATE, "alert": _TARGET_RULE}
for method, path, payload in (
("post", V2_RULES_URL, body),
("put", f"{V2_RULES_URL}/{target_id}", body),
("patch", f"{V2_RULES_URL}/{target_id}", {"description": "viewer patch"}),
("delete", f"{V2_RULES_URL}/{target_id}", None),
("post", f"{V2_RULES_URL}/{target_id}/clone", None),
("post", f"{V2_RULES_URL}/test", body),
):
response = getattr(requests, method)(
signoz.self.host_configs["8080"].get(path),
json=payload,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{method.upper()} {path}: expected 403, got {response.status_code}: {response.text}"
def test_editor_allowed_on_rule_lifecycle(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json={**RULE_TEMPLATE, "alert": _EDITOR_RULE},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
rule_id = response.json()["data"]["id"]
swapped_condition = {
**RULE_CONDITION,
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [SEED_CHANNEL_ALT["name"]]}],
},
}
response = requests.put(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}"),
json={**RULE_TEMPLATE, "alert": _EDITOR_RULE, "condition": swapped_condition},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.patch(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}"),
json={"description": "patched without touching channels"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["condition"]["thresholds"]["spec"][0]["channels"] == [SEED_CHANNEL_ALT["name"]], response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
clone_id = response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/test"),
json={**RULE_TEMPLATE, "alert": _EDITOR_RULE},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for created_id in (rule_id, clone_id):
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{created_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_rule_naming_unknown_channel_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
condition = {
**RULE_CONDITION,
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": ["alert-authz-missing-channel"]}]},
}
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json={**RULE_TEMPLATE, "alert": _EDITOR_RULE, "condition": condition},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -898,6 +1061,277 @@ def test_downtime_widening_to_all_rules_forbidden_without_wildcard_attach(
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_setup_rule_scoped_actor(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)}"),
json={
"description": "",
"transactionGroups": _ACTOR_RULE_TRANSACTION_GROUPS + [transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"])],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_rule_without_channels_allowed_without_channel_attach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
condition = {
**RULE_CONDITION,
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}]},
}
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json={
**RULE_TEMPLATE,
"alert": _ACTOR_RULE,
"condition": condition,
"notificationSettings": {**RULE_TEMPLATE["notificationSettings"], "usePolicy": True},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{response.json()['data']['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_rule_naming_channel_forbidden_without_channel_attach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json={**RULE_TEMPLATE, "alert": _ACTOR_RULE},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{target_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
def test_rule_naming_channel_allowed_once_channel_attach_granted(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)}"),
json={
"description": "",
"transactionGroups": _ACTOR_RULE_TRANSACTION_GROUPS
+ [
transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"]),
transaction_group("attach", "metaresource", "notification-channel", ["*"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json={**RULE_TEMPLATE, "alert": _ACTOR_RULE},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
rule_id = response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
clone_id = response.json()["data"]["id"]
for created_id in (rule_id, clone_id):
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{created_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_channel_swap_forbidden_without_channel_detach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json={**RULE_TEMPLATE, "alert": _ACTOR_SWAP_RULE},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
rule_id = response.json()["data"]["id"]
swapped_condition = {
**RULE_CONDITION,
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [SEED_CHANNEL_ALT["name"]]}],
},
}
response = requests.put(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}"),
json={**RULE_TEMPLATE, "alert": _ACTOR_SWAP_RULE, "condition": swapped_condition},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.patch(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}"),
json={"description": "patched without touching channels"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_rule_mutations_need_telemetry_read_on_query(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
condition = {
**RULE_CONDITION,
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}]},
}
body = {
**RULE_TEMPLATE,
"alert": _ACTOR_RULE,
"condition": condition,
"notificationSettings": {**RULE_TEMPLATE["notificationSettings"], "usePolicy": True},
}
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
rule_id = response.json()["data"]["id"]
logs_body = {**body, "alertType": "LOGS_BASED_ALERT", "condition": {**LOGS_RULE_CONDITION, "thresholds": condition["thresholds"]}}
for method, path, payload in (
("post", V2_RULES_URL, logs_body),
("put", f"{V2_RULES_URL}/{rule_id}", logs_body),
("patch", f"{V2_RULES_URL}/{rule_id}", {"condition": logs_body["condition"]}),
("post", f"{V2_RULES_URL}/test", logs_body),
):
response = getattr(requests, method)(
signoz.self.host_configs["8080"].get(path),
json=payload,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{method.upper()} {path}: expected 403, got {response.status_code}: {response.text}"
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/test"),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)}"),
json={"description": "", "transactionGroups": _ACTOR_RULE_TRANSACTION_GROUPS},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
for method, path, payload in (
("post", V2_RULES_URL, body),
("put", f"{V2_RULES_URL}/{rule_id}", body),
("patch", f"{V2_RULES_URL}/{rule_id}", {"condition": body["condition"]}),
("post", f"{V2_RULES_URL}/{rule_id}/clone", None),
("post", f"{V2_RULES_URL}/test", body),
):
response = getattr(requests, method)(
signoz.self.host_configs["8080"].get(path),
json=payload,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{method.upper()} {path}: expected 403, got {response.status_code}: {response.text}"
response = requests.patch(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}"),
json={"description": "patch without the query needs no telemetry read"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_history_labels_need_telemetry_and_field_grants(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument