diff --git a/VERSION b/VERSION index b30a005..2733a3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.57 +1.1.58 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 87173d9..2ca316e 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -276,7 +276,7 @@ func main() { } authed := v1.Group("") - authed.Use(requireAuth) + authed.Use(requireAuth, handlers.RequireAdminForMutations()) setupHdl.RegisterAuthed(authed) handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed) handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed) diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go index f88c81d..fe70f84 100644 --- a/internal/handlers/middleware.go +++ b/internal/handlers/middleware.go @@ -117,4 +117,27 @@ func tokenFromRequest(c *gin.Context) string { return "" } +// RequireAdminForMutations blocks non-GET/HEAD requests from non-admin +// users. GET and HEAD are always allowed for authenticated users so +// read-only ("viewer") accounts can browse all data. Every state- +// changing request (POST, PUT, PATCH, DELETE) requires role="admin". +// +// Must be mounted AFTER RequireAuth so the token is already in context. +func RequireAdminForMutations() gin.HandlerFunc { + return func(c *gin.Context) { + m := c.Request.Method + if m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions { + c.Next() + return + } + tok := CurrentToken(c) + if tok == nil || tok.Role != "admin" { + response.Err(c, http.StatusForbidden, errors.New("admin_required")) + c.Abort() + return + } + c.Next() + } +} + func ptr[T any](v T) *T { return &v } diff --git a/management-ui/src/api/client.ts b/management-ui/src/api/client.ts index 52bc9e3..cd68d6b 100644 --- a/management-ui/src/api/client.ts +++ b/management-ui/src/api/client.ts @@ -1,4 +1,5 @@ import axios, { type AxiosError } from 'axios' +import { message } from 'antd' import { useAuthStore } from '../stores/auth' @@ -38,6 +39,12 @@ apiClient.interceptors.response.use( window.location.replace('/login') } + // 403 admin_required → viewer account tried to mutate; show a + // one-time notification and let the calling mutation handle the rest. + if (error.response?.status === 403) { + message.error('Keine Berechtigung — dieser Account hat nur Lesezugriff.', 4) + } + // 503 setup_required → kick to /setup so the wizard takes over. if ( error.response?.status === 503 && diff --git a/management-ui/src/components/Layout/Header.tsx b/management-ui/src/components/Layout/Header.tsx index b6f2318..fa72c7e 100644 --- a/management-ui/src/components/Layout/Header.tsx +++ b/management-ui/src/components/Layout/Header.tsx @@ -1,5 +1,5 @@ -import { Button, Dropdown, Select, Space } from 'antd' -import { GlobalOutlined, LogoutOutlined, MenuOutlined, UserOutlined } from '@ant-design/icons' +import { Button, Dropdown, Select, Space, Tag, Tooltip } from 'antd' +import { EyeOutlined, GlobalOutlined, LogoutOutlined, MenuOutlined, UserOutlined } from '@ant-design/icons' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' @@ -55,23 +55,32 @@ export default function Header({ pageTitle, onMenuToggle }: HeaderProps) { popupMatchSelectWidth={false} /> {user && ( - , - label: t('auth.logout'), - onClick: onLogout, - }, - ], - }} - placement="bottomRight" - > - - + <> + {user.role === 'viewer' && ( + + } color="default" style={{ marginRight: 4 }}> + {t('auth.viewerBadge')} + + + )} + , + label: t('auth.logout'), + onClick: onLogout, + }, + ], + }} + placement="bottomRight" + > + + + )} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index b05a931..6dc85b3 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -198,7 +198,9 @@ "logout": "Abmelden", "loginFailed": "Anmeldung fehlgeschlagen", "loggedInAs": "Angemeldet als", - "forgotPassword": "Passwort vergessen?" + "forgotPassword": "Passwort vergessen?", + "viewerBadge": "Nur lesen", + "viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen." }, "reset": { "title": "Admin-Passwort zurücksetzen", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 1391083..f68cf48 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -198,7 +198,9 @@ "logout": "Sign out", "loginFailed": "Sign-in failed", "loggedInAs": "Signed in as", - "forgotPassword": "Forgot your password?" + "forgotPassword": "Forgot your password?", + "viewerBadge": "Read-only", + "viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role." }, "reset": { "title": "Reset admin password",