Merge branch 'fix/sidebar-skip-internal-scope-polls' into 'main'
fix(sidebar): gate outbox+webhook badge polls on INTERNAL scope (no more 403 spam) See merge request 2-6/2-6-4/terravault/ordinis!198
This commit is contained in:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user