fix(sidebar): gate outbox+webhook badge polls on INTERNAL scope (no more 403 spam)

This commit is contained in:
Александр Зимин
2026-05-14 22:41:28 +00:00
parent 55a1171ac4
commit ac74a8fbd9
3 changed files with 68 additions and 8 deletions
+15 -2
View File
@@ -372,7 +372,13 @@ export const outboxStatsQuery = queryOptions({
refetchInterval: 30_000,
})
export const useOutboxStats = () => useQuery(outboxStatsQuery)
/**
* Outbox stats poll. Backend gate INTERNAL scope — пользователь без
* нужной роли получает 403. Передавай {@code enabled=false} для
* unauthenticated/non-INTERNAL users чтобы не спамить console 403'ами.
*/
export const useOutboxStats = (enabled = true) =>
useQuery({ ...outboxStatsQuery, enabled })
export const dlqQuery = (page: number, size: number) =>
queryOptions({
@@ -399,7 +405,14 @@ export const webhookSubscriptionsQuery = queryOptions({
},
})
export const useWebhookSubscriptions = () => useQuery(webhookSubscriptionsQuery)
/**
* Webhook subscriptions list. INTERNAL-scoped (admin/webhooks/...). Same
* pattern as {@link useOutboxStats} — pass {@code enabled=false} для
* pollers (Sidebar badge) когда user не имеет INTERNAL scope, чтобы
* избежать 403 spam.
*/
export const useWebhookSubscriptions = (enabled = true) =>
useQuery({ ...webhookSubscriptionsQuery, enabled })
export const webhookSubscriptionQuery = (id: string) =>
queryOptions({
@@ -140,3 +140,44 @@ export function useCanCreateSchemaDraft(): boolean {
const auth = useAuth()
return auth.isAuthenticated
}
/**
* Имеет ли actor INTERNAL data scope (или RESTRICTED — superset).
*
* <p>Backend ScopeContext (см. ordinis-auth/.../ScopeContext.java) маппит
* Keycloak roles в DataScope: суффиксы {@code -INTERNAL/-RESTRICTED} +
* full role names {@code ordinis-internal / ORDINIS_INTERNAL / *:internal}
* etc. Frontend-side зеркалит логику: ищем в JWT roles такие что normalize
* → INTERNAL or RESTRICTED.
*
* <p>Используется для гейтинга UI surfaces что hit'ят INTERNAL-scope
* endpoints (admin/outbox/stats, admin/webhooks/subscriptions, audit DLQ
* drilldown). Без этого Sidebar polls badges каждые N секунд → 403 spam
* в console для PUBLIC users.
*
* <p>Backend остаётся primary defence — frontend hide это UX optimization.
*/
export function useHasInternalScope(): boolean {
const auth = useAuth()
if (!auth.isAuthenticated) return false
const roles = tokenRoles(auth.user?.access_token)
return roles.some((role) => {
const upper = role.toUpperCase()
return (
upper.endsWith('-INTERNAL') ||
upper.endsWith('_INTERNAL') ||
upper.endsWith(':INTERNAL') ||
upper.endsWith('-RESTRICTED') ||
upper.endsWith('_RESTRICTED') ||
upper.endsWith(':RESTRICTED') ||
upper === 'INTERNAL' ||
upper === 'RESTRICTED' ||
upper === 'ORDINIS-INTERNAL' ||
upper === 'ORDINIS_INTERNAL' ||
upper === 'ORDINIS-RESTRICTED' ||
upper === 'ORDINIS_RESTRICTED' ||
// Admin override — full access как и ROLE_ADMIN
role === ROLE_ADMIN
)
})
}
@@ -17,6 +17,7 @@ import { useCanMutate } from '@/auth/useCanMutate'
import {
useCanReviewRecord,
useCanReviewSchema,
useHasInternalScope,
} from '@/auth/usePermissions'
import {
useDictionaries,
@@ -84,10 +85,13 @@ export function Sidebar() {
// Reviews badge — суммирует record-reviews + schema-reviews pending queues
// через size=1 paged запросы. Gated на authenticated.
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
// Outbox/Webhooks badges — light periodic poll. Backend гейтит, поэтому
// anonymous safe (просто 401 проигнорируется).
const outboxStats = useOutboxStats()
const webhooks = useWebhookSubscriptions()
// Outbox/Webhooks badges — light periodic poll. Backend гейтит INTERNAL
// scope: пользователь без нужной роли получает 403 на каждый poll. Гейтим
// на frontend через useHasInternalScope чтобы не спамить console 403'ами
// на каждой странице. Для PUBLIC users badge остаётся пустым (не false-zero).
const hasInternal = useHasInternalScope()
const outboxStats = useOutboxStats(hasInternal)
const webhooks = useWebhookSubscriptions(hasInternal)
const dictsCount = dictsQuery.data?.length ?? 0
const myDraftsCount =
@@ -453,8 +457,10 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
const myDrafts = useMyDrafts(0, 1)
const mySchemaDrafts = useMySchemaDrafts(0, 1)
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
const outboxStats = useOutboxStats()
const webhooks = useWebhookSubscriptions()
// Same INTERNAL-scope gate as desktop Sidebar (см. line ~92).
const hasInternal = useHasInternalScope()
const outboxStats = useOutboxStats(hasInternal)
const webhooks = useWebhookSubscriptions(hasInternal)
const dictsCount = dictsQuery.data?.length ?? 0
const myDraftsCount =