feat(rbac): Viewer-Rolle durchsetzen — alle Mutations nur für Admins
- middleware.go: RequireAdminForMutations() blockiert POST/PUT/DELETE für Nicht-Admins (GET/HEAD passieren immer durch) - main.go: Middleware in den authed-Gruppe eingehängt — wirkt für alle ~30 Resource-Handler gleichzeitig - api/client.ts: 403 → AntD-Notification "Nur Lesezugriff" statt stiller Fehler - Header: "Nur lesen"-Badge + Tooltip wenn role=viewer - i18n de+en: viewerBadge + viewerHint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -276,7 +276,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authed := v1.Group("")
|
authed := v1.Group("")
|
||||||
authed.Use(requireAuth)
|
authed.Use(requireAuth, handlers.RequireAdminForMutations())
|
||||||
setupHdl.RegisterAuthed(authed)
|
setupHdl.RegisterAuthed(authed)
|
||||||
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed)
|
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed)
|
||||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||||
|
|||||||
@@ -117,4 +117,27 @@ func tokenFromRequest(c *gin.Context) string {
|
|||||||
return ""
|
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 }
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { type AxiosError } from 'axios'
|
import axios, { type AxiosError } from 'axios'
|
||||||
|
import { message } from 'antd'
|
||||||
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
@@ -38,6 +39,12 @@ apiClient.interceptors.response.use(
|
|||||||
window.location.replace('/login')
|
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.
|
// 503 setup_required → kick to /setup so the wizard takes over.
|
||||||
if (
|
if (
|
||||||
error.response?.status === 503 &&
|
error.response?.status === 503 &&
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button, Dropdown, Select, Space } from 'antd'
|
import { Button, Dropdown, Select, Space, Tag, Tooltip } from 'antd'
|
||||||
import { GlobalOutlined, LogoutOutlined, MenuOutlined, UserOutlined } from '@ant-design/icons'
|
import { EyeOutlined, GlobalOutlined, LogoutOutlined, MenuOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -55,6 +55,14 @@ export default function Header({ pageTitle, onMenuToggle }: HeaderProps) {
|
|||||||
popupMatchSelectWidth={false}
|
popupMatchSelectWidth={false}
|
||||||
/>
|
/>
|
||||||
{user && (
|
{user && (
|
||||||
|
<>
|
||||||
|
{user.role === 'viewer' && (
|
||||||
|
<Tooltip title={t('auth.viewerHint')}>
|
||||||
|
<Tag icon={<EyeOutlined />} color="default" style={{ marginRight: 4 }}>
|
||||||
|
{t('auth.viewerBadge')}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
@@ -72,6 +80,7 @@ export default function Header({ pageTitle, onMenuToggle }: HeaderProps) {
|
|||||||
<Space><UserOutlined />{user.actor}</Space>
|
<Space><UserOutlined />{user.actor}</Space>
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -198,7 +198,9 @@
|
|||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||||
"loggedInAs": "Angemeldet als",
|
"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": {
|
"reset": {
|
||||||
"title": "Admin-Passwort zurücksetzen",
|
"title": "Admin-Passwort zurücksetzen",
|
||||||
|
|||||||
@@ -198,7 +198,9 @@
|
|||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
"loginFailed": "Sign-in failed",
|
"loginFailed": "Sign-in failed",
|
||||||
"loggedInAs": "Signed in as",
|
"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": {
|
"reset": {
|
||||||
"title": "Reset admin password",
|
"title": "Reset admin password",
|
||||||
|
|||||||
Reference in New Issue
Block a user