Compare commits

..

2 Commits

Author SHA1 Message Date
vikrantgupta25
bb41877f21 test: expect a deleted user to have no role assignments
test_delete_user's docstring and comment already state roles are revoked on
delete; the assertion checked == 1 only because the rows survived. With the
cleanup in place the deleted user's userRoles is empty.
2026-08-14 00:03:54 +05:30
vikrantgupta25
5e0c9208d7 fix(user): clear role assignments when a user is deleted
Soft-deleting a user revoked the FGA grant but left the user_role rows, so the
role-delete guard still counted the deleted user as an assignee and the role
could never be deleted (detach also refuses deleted users). SoftDeleteUser now
removes the user_role rows in the same transaction, and a migration clears the
orphan rows left by users deleted before this change.
2026-08-13 23:52:10 +05:30
11 changed files with 171 additions and 125 deletions

View File

@@ -274,6 +274,15 @@ func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string)
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete tokens")
}
// delete user_role assignments so the roles can be deleted later
_, err = tx.NewDelete().
Model(new(authtypes.UserRole)).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user roles")
}
// soft delete user
now := time.Now()
_, err = tx.NewUpdate().

View File

@@ -3104,19 +3104,7 @@ func (aH *APIHandler) PreviewLogsPipelinesHandler(w http.ResponseWriter, r *http
return
}
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
return
}
orgID, errv2 := valuer.NewUUID(claims.OrgID)
if errv2 != nil {
render.Error(w, errv2)
return
}
resultLogs, err := aH.LogsParsingPipelineController.PreviewLogsPipelines(r.Context(), orgID, &req)
resultLogs, err := aH.LogsParsingPipelineController.PreviewLogsPipelines(r.Context(), &req)
if err != nil {
render.Error(w, err)
return

View File

@@ -342,7 +342,6 @@ type PipelinesPreviewResponse struct {
func (ic *LogParsingPipelineController) PreviewLogsPipelines(
ctx context.Context,
orgID valuer.UUID,
request *PipelinesPreviewRequest,
) (*PipelinesPreviewResponse, error) {
pipelines, err := ic.enrichPipelinesFilters(ctx, request.Pipelines)
@@ -350,11 +349,6 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
return nil, err
}
// The collector gets the same pipeline prepended over opamp; see RecommendAgentConfig.
if ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
pipelines = append([]pipelinetypes.GettablePipeline{ic.getNormalizePipeline()}, pipelines...)
}
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
if err != nil {
return nil, err

View File

@@ -6,14 +6,11 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -21,7 +18,39 @@ func TestPipelinePreview(t *testing.T) {
require := require.New(t)
testPipelines := []pipelinetypes.GettablePipeline{
makeTestAddAttributePipeline(),
{
StoreablePipeline: pipelinetypes.StoreablePipeline{
OrderID: 1,
Name: "pipeline1",
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
OrderId: 1,
ID: "add",
Type: "add",
Field: "attributes.test",
Value: "val",
Enabled: true,
Name: "test add",
},
},
},
{
StoreablePipeline: pipelinetypes.StoreablePipeline{
OrderID: 2,
@@ -119,93 +148,6 @@ func TestPipelinePreview(t *testing.T) {
}
func TestPipelinePreviewNormalizesBodyWithJSONBodyEnabled(t *testing.T) {
controller := &LogParsingPipelineController{fl: flaggertest.WithUseJSONBody(t, true)}
result, err := controller.PreviewLogsPipelines(
context.Background(),
valuer.GenerateUUID(),
&PipelinesPreviewRequest{
Pipelines: []pipelinetypes.GettablePipeline{makeTestAddAttributePipeline()},
Logs: []model.SignozLog{
makeTestSignozLog("test log body", map[string]interface{}{"method": "GET"}),
makeTestSignozLog(
`{"level":"error","msg":"json log body"}`,
map[string]interface{}{"method": "GET"},
),
},
},
)
require.NoError(t, err)
require.Len(t, result.OutputLogs, 2)
assert.Equal(t, `{"message":"test log body"}`, result.OutputLogs[0].Body)
assert.Equal(
t,
`{"level":"error","message":"json log body"}`,
result.OutputLogs[1].Body,
)
assert.Equal(t, "val", result.OutputLogs[0].Attributes_string["test"])
}
func TestPipelinePreviewKeepsBodyAsIsWithJSONBodyDisabled(t *testing.T) {
controller := &LogParsingPipelineController{fl: flaggertest.WithUseJSONBody(t, false)}
result, err := controller.PreviewLogsPipelines(
context.Background(),
valuer.GenerateUUID(),
&PipelinesPreviewRequest{
Pipelines: []pipelinetypes.GettablePipeline{makeTestAddAttributePipeline()},
Logs: []model.SignozLog{
makeTestSignozLog("test log body", map[string]interface{}{"method": "GET"}),
},
},
)
require.NoError(t, err)
require.Len(t, result.OutputLogs, 1)
assert.Equal(t, "test log body", result.OutputLogs[0].Body)
assert.Equal(t, "val", result.OutputLogs[0].Attributes_string["test"])
}
func makeTestAddAttributePipeline() pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
OrderID: 1,
Name: "pipeline1",
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
OrderId: 1,
ID: "add",
Type: "add",
Field: "attributes.test",
Value: "val",
Enabled: true,
Name: "test add",
},
},
}
}
func TestGrokParsingProcessor(t *testing.T) {
require := require.New(t)

View File

@@ -240,6 +240,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
sqlmigration.NewBackfillSavedViewRequestTypeFactory(sqlstore),
sqlmigration.NewRestructureAuthDomainConfigFactory(sqlstore),
sqlmigration.NewDeleteOrphanUserRolesFactory(),
)
}

View File

@@ -0,0 +1,65 @@
package sqlmigration
import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type deleteOrphanUserRoles struct{}
func NewDeleteOrphanUserRolesFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("delete_orphan_user_roles"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &deleteOrphanUserRoles{}, nil
},
)
}
func (migration *deleteOrphanUserRoles) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *deleteOrphanUserRoles) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var deletedUserIDs []string
err = tx.NewSelect().
Model(new(types.User)).
Column("id").
Where("status = ?", types.UserStatusDeleted).
Scan(ctx, &deletedUserIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
if len(deletedUserIDs) == 0 {
return tx.Commit()
}
_, err = tx.NewDelete().
Model(new(authtypes.UserRole)).
Where("user_id IN (?)", bun.In(deletedUserIDs)).
Exec(ctx)
if err != nil {
return err
}
return tx.Commit()
}
func (migration *deleteOrphanUserRoles) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -123,6 +123,23 @@ func (b *StatementBuilder) Build(
return nil, err
}
// TODO(srikanthccv): move the missing-key detection into the where clause
// visitor. Doing it here over the lexer-derived selectors can't tell a key
// from a value, so dashboard variables and bare literals in value position
// (e.g. `service.name = $service`) get flagged as missing keys. We still add
// a labels fallback for any unresolved selector so the query can be built,
// but we no longer emit a warning until the visitor can classify keys.
for _, sel := range keySelectors {
if _, ok := keys[sel.Name]; !ok {
keys[sel.Name] = []*telemetrytypes.TelemetryFieldKey{{
Name: sel.Name,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
Signal: telemetrytypes.SignalMetrics,
}}
}
}
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
return b.buildPipelineStatement(ctx, orgID, start, end, query, keys, variables)
@@ -162,10 +179,9 @@ func (b *StatementBuilder) buildPipelineStatement(
var timeSeriesCTE string
var timeSeriesCTEArgs []any
var filterWarnings []string
var err error
if timeSeriesCTE, timeSeriesCTEArgs, filterWarnings, err = b.buildTimeSeriesCTE(ctx, orgID, tsStart, tsEnd, cteQuery, keys, variables, tsTable); err != nil {
if timeSeriesCTE, timeSeriesCTEArgs, err = b.buildTimeSeriesCTE(ctx, orgID, tsStart, tsEnd, cteQuery, keys, variables, tsTable); err != nil {
return nil, err
}
@@ -220,7 +236,6 @@ func (b *StatementBuilder) buildPipelineStatement(
if err != nil {
return nil, err
}
mainStmt.Warnings = append(mainStmt.Warnings, filterWarnings...)
if reducedFragments == nil {
return mainStmt, nil
}
@@ -468,7 +483,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
tsTable string,
) (string, []any, []string, error) {
) (string, []any, error) {
sb := sqlbuilder.NewSelectBuilder()
var preparedWhereClause querybuilder.PreparedWhereClause
@@ -488,7 +503,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
EndNs: end,
})
if err != nil {
return "", nil, nil, err
return "", nil, err
}
}
@@ -498,7 +513,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
for i, g := range query.GroupBy {
col, err := b.fm.ColumnExpressionFor(ctx, orgID, start, end, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, nil, err
return "", nil, err
}
sb.SelectMore(fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(col), GroupByColumnAlias(i, g.Name)))
}
@@ -527,7 +542,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
sb.GroupBy(GroupByAliases(query.GroupBy)...)
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return fmt.Sprintf("(%s) AS filtered_time_series", q), args, preparedWhereClause.Warnings, nil
return fmt.Sprintf("(%s) AS filtered_time_series", q), args, nil
}
func (b *StatementBuilder) buildTemporalAggregationCTE(

View File

@@ -316,9 +316,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `__GROUP_BY_KEY_0_k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_k8s.statefulset.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
Warnings: []string{"label `k8s.statefulset.name` not found in metadata; check the label name for typos"},
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `__GROUP_BY_KEY_0_k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_k8s.statefulset.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
},
expectedErr: nil,
},

View File

@@ -129,4 +129,4 @@ def test_delete_user(
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["status"] == "deleted"
assert len(data["userRoles"]) == 1
assert len(data["userRoles"]) == 0

View File

@@ -175,9 +175,8 @@ def test_metrics_filter_unknown_label_matches_nothing(
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""A filter on a label no metric carries resolves to JSONExtractString(labels,'<missing>')
= '' and matches nothing: metrics returns 200 with an empty result, and warns that the
label is absent from metadata. Only keys are flagged — a value or dashboard variable in
value position never reaches the condition builder, so it cannot be mistaken for one."""
= '' and matches nothing: metrics returns 200 with an empty result and — unlike the
logs/traces synthesize path — emits no key-not-found warning."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(
[
@@ -209,7 +208,7 @@ def test_metrics_filter_unknown_label_matches_nothing(
)
assert response.status_code == HTTPStatus.OK, response.text
assert querier.get_scalar_table_data(response.json()) == []
assert [w["message"] for w in querier.get_all_warnings(response.json())] == ["label `does_not_exist_label` not found in metadata; check the label name for typos"]
assert querier.get_all_warnings(response.json()) == []
def test_metrics_full_text_filter_does_not_error(

View File

@@ -267,6 +267,40 @@ def test_delete_role_with_assignee_guarded(
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
def test_delete_role_after_deleting_assigned_user(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = create_role(admin_token, "crud-deleted-assignee-role", [transaction_group("read", "metaresource", "dashboard", ["*"])])
user_id = create_active_user(
signoz,
admin_token,
email="crud+deleted-assignee@integration.test",
role="signoz-viewer",
password=CRUD_ASSIGNEE_USER_PASSWORD,
name="crud-deleted-assignee-user",
)
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": user_id, "roleId": role_id},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete role after deleting its only assignee: {resp.text}"
def test_delete_removes_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument