diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index c9c0882..c83d187 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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({ diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts index 2ed4bca..f77ef7b 100644 --- a/ordinis-admin-ui/src/auth/usePermissions.ts +++ b/ordinis-admin-ui/src/auth/usePermissions.ts @@ -140,3 +140,44 @@ export function useCanCreateSchemaDraft(): boolean { const auth = useAuth() return auth.isAuthenticated } + +/** + * Имеет ли actor INTERNAL data scope (или RESTRICTED — superset). + * + *

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. + * + *

Используется для гейтинга UI surfaces что hit'ят INTERNAL-scope + * endpoints (admin/outbox/stats, admin/webhooks/subscriptions, audit DLQ + * drilldown). Без этого Sidebar polls badges каждые N секунд → 403 spam + * в console для PUBLIC users. + * + *

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 + ) + }) +} diff --git a/ordinis-admin-ui/src/components/layout/Sidebar.tsx b/ordinis-admin-ui/src/components/layout/Sidebar.tsx index bc23958..eae17c9 100644 --- a/ordinis-admin-ui/src/components/layout/Sidebar.tsx +++ b/ordinis-admin-ui/src/components/layout/Sidebar.tsx @@ -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 =