Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions backend/pkg/server/services/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,12 @@ func (s *UserService) PatchUser(c *gin.Context) {
return
}

// Use map to update fields to avoid GORM ignoring zero values (false for bool)
updates := map[string]any{
"name": user.Name,
"status": user.Status,
}

if user.Password != "" {
var encPassword []byte
encPassword, err = rdb.EncryptPassword(user.Password)
Expand All @@ -660,22 +666,42 @@ func (s *UserService) PatchUser(c *gin.Context) {
response.Error(c, response.ErrInternal, err)
return
}
// Use map to update fields to avoid GORM ignoring zero values (false for bool)
updates := map[string]any{
"name": user.Name,
"status": user.Status,
"password": string(encPassword),
"password_change_required": false,
updates["password"] = string(encPassword)
updates["password_change_required"] = false
}

// Role changes are an administrative action: they require users.edit, they may
// not grant privileges the caller does not hold, and nobody may change their
// own role, which would otherwise let the last administrator demote themselves.
if user.RoleID != 0 && user.RoleID != existingUser.RoleID {
if !slices.Contains(privs, "users.edit") || uhash == hash {
logger.FromContext(c).Errorf("error changing user role: permission not found")
response.Error(c, response.ErrNotPermitted, nil)
return
}
err = s.db.Model(&existingUser).Updates(updates).Error
} else {
updates := map[string]any{
"name": user.Name,
"status": user.Status,

var privsCurrentUser, privsTargetRole []string
if privsCurrentUser, err = s.GetUserPrivileges(c, c.GetUint64("rid")); err != nil {
logger.FromContext(c).WithError(err).Errorf("error getting current user privileges")
response.Error(c, response.ErrInternal, err)
return
}
if privsTargetRole, err = s.GetUserPrivileges(c, user.RoleID); err != nil {
logger.FromContext(c).WithError(err).Errorf("error getting target role privileges")
response.Error(c, response.ErrInternal, err)
return
}
err = s.db.Model(&existingUser).Updates(updates).Error
if !s.CheckPrivilege(c, privsCurrentUser, privsTargetRole) {
logger.FromContext(c).Errorf("error checking target role privileges")
response.Error(c, response.ErrNotPermitted, nil)
return
}

updates["role_id"] = user.RoleID
}

err = s.db.Model(&existingUser).Updates(updates).Error

if err != nil {
logger.FromContext(c).WithError(err).Errorf("error updating user by hash '%s'", hash)
response.Error(c, response.ErrInternal, err)
Expand Down
153 changes: 153 additions & 0 deletions backend/pkg/server/services/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,3 +553,156 @@ func TestIsUniqueViolation(t *testing.T) {
assert.False(t, isUniqueViolation(errors.New("connection refused")))
assert.False(t, isUniqueViolation(nil))
}

// patchUserContext builds a request context for PatchUser as the caller identified
// by callerHash with the given role and privileges.
func patchUserContext(
t *testing.T,
target models.User,
roleID uint64,
callerID uint64,
callerHash string,
callerRoleID uint64,
privs []string,
) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()

gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

c.Set("uid", callerID)
c.Set("rid", callerRoleID)
c.Set("uhash", callerHash)
c.Set("prm", privs)
c.Params = gin.Params{{Key: "hash", Value: target.Hash}}

payload := models.UserPassword{User: models.User{
Hash: target.Hash,
ID: target.ID,
Mail: target.Mail,
Name: target.Name,
RoleID: roleID,
Status: target.Status,
Type: target.Type,
}}

body, err := json.Marshal(payload)
require.NoError(t, err)

c.Request, _ = http.NewRequest("PUT", "/users/"+target.Hash, bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")

return c, w
}

// seedPatchUser inserts a user the tests can then patch.
func seedPatchUser(t *testing.T, db *gorm.DB, hash, mail string, roleID uint64) models.User {
t.Helper()

user := models.User{
Hash: hash,
Mail: mail,
Name: "Target User",
RoleID: roleID,
Status: models.UserStatusActive,
Type: models.UserTypeLocal,
}
require.NoError(t, db.Create(&user).Error)

return user
}

func TestPatchUser_ChangesRoleWithEditPrivilege(t *testing.T) {
db := setupTestDB(t)
defer db.Close()

service := NewUserService(db, auth.NewUserCache(db))
target := seedPatchUser(t, db, "aa000000000000000000000000000001", "target-a@test.com", 2)

c, w := patchUserContext(t, target, 1, 1, "bb000000000000000000000000000001", 1, []string{"users.edit"})
service.PatchUser(c)

assert.Equal(t, http.StatusOK, w.Code)

var updated models.User
require.NoError(t, db.Where("hash = ?", target.Hash).First(&updated).Error)
assert.Equal(t, uint64(1), updated.RoleID, "role should be updated to Admin")
}

func TestPatchUser_RejectsRoleChangeWithoutEditPrivilege(t *testing.T) {
db := setupTestDB(t)
defer db.Close()

service := NewUserService(db, auth.NewUserCache(db))
target := seedPatchUser(t, db, "aa000000000000000000000000000002", "target-b@test.com", 2)

// The caller may patch itself, but that does not extend to role changes.
c, w := patchUserContext(t, target, 1, target.ID, target.Hash, 2, []string{})
service.PatchUser(c)

assert.Equal(t, http.StatusForbidden, w.Code)

var updated models.User
require.NoError(t, db.Where("hash = ?", target.Hash).First(&updated).Error)
assert.Equal(t, uint64(2), updated.RoleID, "role must stay unchanged")
}

func TestPatchUser_RejectsSelfRoleChange(t *testing.T) {
db := setupTestDB(t)
defer db.Close()

service := NewUserService(db, auth.NewUserCache(db))
admin := seedPatchUser(t, db, "bb000000000000000000000000000002", "admin-self@test.com", 1)

c, w := patchUserContext(t, admin, 2, admin.ID, admin.Hash, 1, []string{"users.edit"})
service.PatchUser(c)

assert.Equal(t, http.StatusForbidden, w.Code, "an administrator must not demote themselves")

var updated models.User
require.NoError(t, db.Where("hash = ?", admin.Hash).First(&updated).Error)
assert.Equal(t, uint64(1), updated.RoleID)
}

func TestPatchUser_RejectsPrivilegeEscalation(t *testing.T) {
db := setupTestDB(t)
defer db.Close()

// A role that can edit users but holds fewer privileges than Admin.
require.NoError(t, db.Exec("INSERT INTO roles (id, name) VALUES (3, 'Operator')").Error)
require.NoError(t, db.Exec(`INSERT INTO privileges (role_id, name) VALUES
(3, 'users.view'), (3, 'users.edit'), (3, 'roles.view')`).Error)

service := NewUserService(db, auth.NewUserCache(db))
target := seedPatchUser(t, db, "aa000000000000000000000000000003", "target-c@test.com", 2)

// Operator tries to promote the target to Admin, which holds privileges the
// operator does not have.
c, w := patchUserContext(t, target, 1, 1, "cc000000000000000000000000000001", 3, []string{"users.edit"})
service.PatchUser(c)

assert.Equal(t, http.StatusForbidden, w.Code)

var updated models.User
require.NoError(t, db.Where("hash = ?", target.Hash).First(&updated).Error)
assert.Equal(t, uint64(2), updated.RoleID, "privilege escalation must be refused")
}

func TestPatchUser_KeepsRoleWhenUnchanged(t *testing.T) {
db := setupTestDB(t)
defer db.Close()

service := NewUserService(db, auth.NewUserCache(db))
target := seedPatchUser(t, db, "aa000000000000000000000000000004", "target-d@test.com", 2)

c, w := patchUserContext(t, target, 2, 1, "bb000000000000000000000000000003", 1, []string{"users.edit"})
service.PatchUser(c)

assert.Equal(t, http.StatusOK, w.Code)

var updated models.User
require.NoError(t, db.Where("hash = ?", target.Hash).First(&updated).Error)
assert.Equal(t, uint64(2), updated.RoleID)
assert.Equal(t, "Target User", updated.Name)
}
1 change: 1 addition & 0 deletions frontend/e2e/route-manifest.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const EXCLUDED: Record<string, string> = {
'/oauth/result': 'OAuth popup landing; only meaningful mid-OAuth-roundtrip',
'/settings': 'redirects to /settings/account',
'/settings/account': 'needs an account cassette + visual baseline before joining the sweep',
'/settings/users': 'needs a users cassette + visual baseline before joining the sweep',
'/templates/new': 'create-mode variant of the template detail page',
};

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const Templates = lazy(() => import('@/pages/templates/templates'));
const OAuthResult = lazy(() => import('@/pages/oauth-result'));
const SettingsAccount = lazy(() => import('@/pages/settings/settings-account'));
const SettingsAPITokens = lazy(() => import('@/pages/settings/settings-api-tokens'));
const SettingsUsers = lazy(() => import('@/pages/settings/settings-users'));
const SettingsPrompt = lazy(() => import('@/pages/settings/settings-prompt'));
const SettingsPrompts = lazy(() => import('@/pages/settings/settings-prompts'));
const SettingsProvider = lazy(() => import('@/pages/settings/settings-provider'));
Expand Down Expand Up @@ -231,6 +232,11 @@ const router = createBrowserRouter(
handle={routeTitles.apiTokens}
path="api-tokens"
/>
<Route
element={<SettingsUsers />}
handle={routeTitles.users}
path="users"
/>
<Route
element={
<Navigate
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { SidebarProvider } from '@/components/ui/sidebar';

const { authState } = vi.hoisted(() => ({ authState: { privileges: [] as string[] } }));

vi.mock('@/providers/user-provider', () => ({
useUser: () => ({ authInfo: { privileges: authState.privileges, type: 'user' } }),
}));

import { SettingsSidebar } from './settings-sidebar';

beforeEach(() => {
authState.privileges = [];
});

function renderSidebar(entry: { pathname: string; state?: unknown }) {
return render(
<MemoryRouter initialEntries={[entry]}>
Expand Down Expand Up @@ -51,3 +61,19 @@ describe('SettingsSidebar "Back to App"', () => {
expect(backToApp()).toHaveAttribute('href', '/dashboard');
});
});

describe('SettingsSidebar privileged items', () => {
it('hides Users from accounts without the users.view privilege', () => {
renderSidebar({ pathname: '/settings/account' });

expect(screen.queryByRole('link', { name: /Users/ })).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: /Account/ })).toBeInTheDocument();
});

it('shows Users once the account may view them', () => {
authState.privileges = ['users.view'];
renderSidebar({ pathname: '/settings/account' });

expect(screen.getByRole('link', { name: /Users/ })).toHaveAttribute('href', '/settings/users');
});
});
17 changes: 15 additions & 2 deletions frontend/src/components/layouts/settings/settings-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';

import { ArrowLeft, FileText, Key, Plug, Settings as SettingsIcon, User } from 'lucide-react';
import { ArrowLeft, FileText, Key, Plug, Settings as SettingsIcon, User, Users } from 'lucide-react';
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';

Expand All @@ -17,11 +17,14 @@ import {
} from '@/components/ui/sidebar';
import { routes } from '@/lib/routes';
import { getSafeReturnUrl } from '@/lib/utils/auth';
import { useUser } from '@/providers/user-provider';

interface MenuItem {
icon?: ReactNode;
id: string;
path: string;
// Privilege the account must hold for the item to be shown at all.
privilege?: string;
title: string;
}

Expand Down Expand Up @@ -54,10 +57,20 @@ const menuItems: readonly MenuItem[] = [
path: routes.settings.apiTokens,
title: 'API Tokens',
},
{
icon: <Users className="size-4" />,
id: 'users',
path: routes.settings.users,
privilege: 'users.view',
title: 'Users',
},
] as const;

export function SettingsSidebar() {
const location = useLocation();
const { authInfo } = useUser();
const privileges = authInfo?.privileges ?? [];
const visibleItems = menuItems.filter((item) => !item.privilege || privileges.includes(item.privilege));
const [returnUrl] = useState(() =>
getSafeReturnUrl((location.state as null | { from?: string })?.from ?? null, routes.flows),
);
Expand All @@ -80,7 +93,7 @@ export function SettingsSidebar() {
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menuItems.map((item) => (
{visibleItems.map((item) => (
<SettingsSidebarMenuItem
item={item}
key={item.id}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/route-titles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const routeTitles = {

providers: { title: 'Providers' },


resources: { title: 'Resources' },

template: {
Expand All @@ -96,4 +97,6 @@ export const routeTitles = {
},

templates: { title: 'Templates' },

users: { title: 'Users' },
} as const satisfies Record<string, RouteTitleHandle>;
1 change: 1 addition & 0 deletions frontend/src/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const routes = {
provider: (id: string) => `/settings/providers/${id}`,
providers: '/settings/providers',
root: '/settings',
users: '/settings/users',
},

template: (id: number | string) => `/templates/${id}`,
Expand Down
Loading