fix(sidebar): gate outbox+webhook badge polls on INTERNAL scope (no more 403 spam)
This commit is contained in:
@@ -372,7 +372,13 @@ export const outboxStatsQuery = queryOptions({
|
|||||||
refetchInterval: 30_000,
|
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) =>
|
export const dlqQuery = (page: number, size: number) =>
|
||||||
queryOptions({
|
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) =>
|
export const webhookSubscriptionQuery = (id: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
|
|||||||
@@ -140,3 +140,44 @@ export function useCanCreateSchemaDraft(): boolean {
|
|||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
return auth.isAuthenticated
|
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 {
|
import {
|
||||||
useCanReviewRecord,
|
useCanReviewRecord,
|
||||||
useCanReviewSchema,
|
useCanReviewSchema,
|
||||||
|
useHasInternalScope,
|
||||||
} from '@/auth/usePermissions'
|
} from '@/auth/usePermissions'
|
||||||
import {
|
import {
|
||||||
useDictionaries,
|
useDictionaries,
|
||||||
@@ -84,10 +85,13 @@ export function Sidebar() {
|
|||||||
// Reviews badge — суммирует record-reviews + schema-reviews pending queues
|
// Reviews badge — суммирует record-reviews + schema-reviews pending queues
|
||||||
// через size=1 paged запросы. Gated на authenticated.
|
// через size=1 paged запросы. Gated на authenticated.
|
||||||
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
|
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
|
||||||
// Outbox/Webhooks badges — light periodic poll. Backend гейтит, поэтому
|
// Outbox/Webhooks badges — light periodic poll. Backend гейтит INTERNAL
|
||||||
// anonymous safe (просто 401 проигнорируется).
|
// scope: пользователь без нужной роли получает 403 на каждый poll. Гейтим
|
||||||
const outboxStats = useOutboxStats()
|
// на frontend через useHasInternalScope чтобы не спамить console 403'ами
|
||||||
const webhooks = useWebhookSubscriptions()
|
// на каждой странице. Для PUBLIC users badge остаётся пустым (не false-zero).
|
||||||
|
const hasInternal = useHasInternalScope()
|
||||||
|
const outboxStats = useOutboxStats(hasInternal)
|
||||||
|
const webhooks = useWebhookSubscriptions(hasInternal)
|
||||||
|
|
||||||
const dictsCount = dictsQuery.data?.length ?? 0
|
const dictsCount = dictsQuery.data?.length ?? 0
|
||||||
const myDraftsCount =
|
const myDraftsCount =
|
||||||
@@ -453,8 +457,10 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
const myDrafts = useMyDrafts(0, 1)
|
const myDrafts = useMyDrafts(0, 1)
|
||||||
const mySchemaDrafts = useMySchemaDrafts(0, 1)
|
const mySchemaDrafts = useMySchemaDrafts(0, 1)
|
||||||
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
|
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
|
||||||
const outboxStats = useOutboxStats()
|
// Same INTERNAL-scope gate as desktop Sidebar (см. line ~92).
|
||||||
const webhooks = useWebhookSubscriptions()
|
const hasInternal = useHasInternalScope()
|
||||||
|
const outboxStats = useOutboxStats(hasInternal)
|
||||||
|
const webhooks = useWebhookSubscriptions(hasInternal)
|
||||||
|
|
||||||
const dictsCount = dictsQuery.data?.length ?? 0
|
const dictsCount = dictsQuery.data?.length ?? 0
|
||||||
const myDraftsCount =
|
const myDraftsCount =
|
||||||
|
|||||||
Reference in New Issue
Block a user