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
5 changed files with 110 additions and 1 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

@@ -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

@@ -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

@@ -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