fix(web): rule pages tell non-admins that contributing is admin-only instead of failing with 403

The analyze and gather rule pages loaded the community submissions list for
every role. rules/submissions is TenantAdminOrGA, so an Operator or Viewer got
a 403 and a red "Failed to load your community submissions" banner on every
page visit (16 denials in 24 h from two operators of one tenant).

The pages now skip the list for callers who cannot read it and the
contribution box says that only tenant admins can contribute and follow the
tenant's submissions. Tenant admins, Global Admins and a global-scope override
are unchanged; the backend policy stays as it is.

Bumps the internal and .claude/commands gitlinks (platform-health marker
catalog, backlog).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-21 13:27:25 +02:00
parent c701d0d694
commit f47d961947
5 changed files with 36 additions and 21 deletions
@@ -112,6 +112,9 @@ export default function AnalyzeRulesPage() {
// A read-only Global Reader — and an own-tenant admin viewing a FOREIGN tenant (cross-tenant override) —
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
// Community submissions are TenantAdminOrGA on the own tenant; only a global-scope override reads them through
// the operator list. Everyone else (Operator, Viewer) gets a hint instead of a 403 on the list call.
const submissionsAdminOnly = isReadOnly && !isGlobalOverride;
// Cross-tenant write routing: in a Global Admin override the JWT-scoped rules/analyze/{id} route
// resolves the tenant from the caller's token and would silently upsert into the GA's OWN tenant
@@ -610,15 +613,17 @@ export default function AnalyzeRulesPage() {
) : (
<div className="space-y-6">
{/* Community contribution: submit own custom rules for the shared pool; the list shows their status. */}
<CommunityContributionBox onContribute={isReadOnly ? undefined : () => setShowSubmitModal(true)} />
<MySubmissionsList
kind="analyze"
getAccessToken={getAccessToken}
refreshKey={submissionsRefreshKey}
overrideTenantId={isGlobalOverride ? effectiveTenantId : undefined}
readOnly={isReadOnly}
onError={showError}
/>
<CommunityContributionBox onContribute={isReadOnly ? undefined : () => setShowSubmitModal(true)} adminOnly={submissionsAdminOnly} />
{!submissionsAdminOnly && (
<MySubmissionsList
kind="analyze"
getAccessToken={getAccessToken}
refreshKey={submissionsRefreshKey}
overrideTenantId={isGlobalOverride ? effectiveTenantId : undefined}
readOnly={isReadOnly}
onError={showError}
/>
)}
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
@@ -83,6 +83,9 @@ export default function GatherRulesPage() {
// A read-only Global Reader — and an own-tenant admin viewing a FOREIGN tenant (cross-tenant override) —
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
// Community submissions are TenantAdminOrGA on the own tenant; only a global-scope override reads them through
// the operator list. Everyone else (Operator, Viewer) gets a hint instead of a 403 on the list call.
const submissionsAdminOnly = isReadOnly && !isGlobalOverride;
// Cross-tenant write routing: in a Global Admin override the JWT-scoped rules/gather/{id} route
// resolves the tenant from the caller's token and would silently upsert into the GA's OWN tenant
@@ -444,15 +447,17 @@ export default function GatherRulesPage() {
)}
{/* Community contribution: submit own custom rules for the shared pool; the list shows their status. */}
<CommunityContributionBox onContribute={isReadOnly ? undefined : () => setShowSubmitModal(true)} />
<MySubmissionsList
kind="gather"
getAccessToken={getAccessToken}
refreshKey={submissionsRefreshKey}
overrideTenantId={isGlobalOverride ? effectiveTenantId : undefined}
readOnly={isReadOnly}
onError={showError}
/>
<CommunityContributionBox onContribute={isReadOnly ? undefined : () => setShowSubmitModal(true)} adminOnly={submissionsAdminOnly} />
{!submissionsAdminOnly && (
<MySubmissionsList
kind="gather"
getAccessToken={getAccessToken}
refreshKey={submissionsRefreshKey}
overrideTenantId={isGlobalOverride ? effectiveTenantId : undefined}
readOnly={isReadOnly}
onError={showError}
/>
)}
{/* Summary Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
@@ -6,10 +6,12 @@ import { DOCS_PATHS } from "@/lib/docsPaths";
interface CommunityContributionBoxProps {
/** Undefined hides the button (read-only roles, no custom rules yet). */
onContribute?: () => void;
/** The caller's role cannot contribute at all (contributing is tenant-admin only): say so instead of just hiding the button. */
adminOnly?: boolean;
}
/** The community call-to-action on the rule pages: contribute a rule here, report bugs on GitHub. */
export function CommunityContributionBox({ onContribute }: CommunityContributionBoxProps) {
export function CommunityContributionBox({ onContribute, adminOnly }: CommunityContributionBoxProps) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-start space-x-3 flex-1">
@@ -22,6 +24,9 @@ export function CommunityContributionBox({ onContribute }: CommunityContribution
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
open a GitHub issue
</a>.
{adminOnly && (
<span className="block mt-1 text-blue-700">Only tenant admins can contribute rules and follow the tenant&apos;s submissions — ask an admin of your tenant to submit it.</span>
)}
</p>
</div>
{onContribute && (