Skip to content

fix(security): enforce disable_* alarm-type settings server-side (#236)#238

Merged
hokiepokedad2 merged 2 commits intomainfrom
fix/236-server-side-disable-alarm-enforcement
Apr 17, 2026
Merged

fix(security): enforce disable_* alarm-type settings server-side (#236)#238
hokiepokedad2 merged 2 commits intomainfrom
fix/236-server-side-disable-alarm-enforcement

Conversation

@hokiepokedad2
Copy link
Copy Markdown
Contributor

Summary

Closes #236.

disable_* alarm-type site settings only hid navigation in the SPA — every alarm controller was reachable via direct API calls regardless of the toggle, and several service-to-service paths (quick-pick apply, profile duplicate/import, cleaning toggle) wrote alarms past the controller layer entirely.

This PR enforces the toggles server-side with defense-in-depth across three layers — controller filter, service-layer guard, and global exception handler — so no future caller can accidentally bypass the gate. Frontend also gets a route guard, a 403 interceptor refinement, and the admin no longer sees disabled nav items (the original bug was a UI/API mismatch — leaving one in place for admins would have recreated it in miniature).

What's in here

Backend gating

  • New IFeatureGate (Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IFeatureGate.cs) wrapping ISiteSettingService.GetBoolAsync with IsEnabledAsync / EnsureEnabledAsync. The latter throws FeatureDisabledException. Logs at Info for an audit trail.
  • New DisableFeatureKeys (Core/Pgan.PoracleWebNet.Core.Models/) — single source of truth for the 9 disable keys plus a tracking-type→key map used by ProfileOverviewService and TestAlertController.
  • [RequireFeatureEnabled] resource filter on all 10 alarm controllers. TestAlertController does the same check inline since the alarm type is a path parameter.
  • Service-layer EnsureEnabledAsync calls in every alarm service's CreateAsync, BulkCreateAsync, and UpdateAsync — closes the bypasses found by review (QuickPickService → MonsterService.CreateAsync, etc.). CleaningService.ToggleCleanAsync and ProfileOverviewService.{DuplicateProfile,ImportAlarms}Async pre-validate before any state mutation so a partial copy can't fail mid-loop.
  • Global FeatureDisabledExceptionFilter maps any service-thrown exception to HTTP 403; both response paths share FeatureDisabledResponse.Create(disableKey) so the wire format can't drift.

Frontend follow-through

  • disabledFeatureGuard(disableKey) factory applied to all 9 alarm routes. Wraps loadOnce() in try/catch — if settings load fails, allows navigation since the backend still enforces.
  • 403 interceptor inspects error.error?.disableKey and routes feature-disabled responses through a clearer toast + redirect to /dashboard instead of leaving the user on a broken page.
  • Left nav now hides disabled items for admins too. Empty alarm-group label and divider are also suppressed.
  • ERROR.FEATURE_DISABLED translation added to all 11 locales.

Performance

  • SiteSettingService.GetByKeyAsync wrapped in IMemoryCache (5-min TTL, explicit invalidation on writes). Without this the dashboard would add ~10 MySQL roundtrips per page load (one gate check per alarm endpoint).

Hygiene

  • disable_fort_changes was missing from SettingsMigrationService.BooleanKeys and CategoryMap (pre-existing oversight) — now seeded.
  • New Feature Gating subsection in CLAUDE.md documenting the pattern for future contributors.

Behavioral changes admins should know

  • The toggle now applies to admins too. Previously the SPA admin-bypassed the nav filter. Now if you disable a feature it disappears for everyone, including you. To use it again, flip the toggle off in Admin → Settings.
  • No disable_eggs setting. Eggs share the raid disable toggle (they share the raid UI in the SPA). EggService and EggController use DisableFeatureKeys.Raids with explicit comments.
  • Existing alarms remain in the DB. This PR blocks new create/update/clean operations against a disabled type — it doesn't delete pre-existing rows or stop PoracleNG from firing alerts on them. If you want to fully retire an alarm type, ask users to delete (or use a future bulk-purge tool).

Test plan

  • Backend: dotnet test — 1013 passed (added ~30 tests across FeatureGateTests, FeatureDisabledExceptionFilterTests, RequireFeatureEnabledAttributeTests, per-service disable tests on MonsterService and CleaningService, ProfileOverviewService pre-validation tests, ProfileOverviewController exception-propagation tests).
  • Frontend: npm test — 627 passed (new disabled-feature.guard.spec.ts including loadOnce-fails fallback; 403-with-disableKey case in error.interceptor.spec.ts).
  • Lint: npm run lint clean.
  • Prettier: npm run prettier-check clean.
  • dotnet format --verify-no-changes reports zero new violations from this PR.
  • Docker image rebuilt locally and container recreated; healthy.
  • Smoke-test in browser:
    • Toggle disable_mons=true as admin. Confirm the Pokemon nav item disappears for both admin and non-admin sessions.
    • Try POST /api/monsters with curl as a non-admin → 403 with { disableKey: "disable_mons" }.
    • Try applying the "Standard Pokemon" quick pick → 403.
    • Try duplicating a profile that has monster alarms → 403 with no partial state.
    • Try the test-alert button on an existing pokemon alarm → 403.
    • Browse to /pokemon directly → guard redirects to /dashboard with toast.
    • Toggle off; confirm everything works again.

@github-actions github-actions Bot added the fix label Apr 17, 2026
Comment on lines +131 to +138
foreach (var alarm in arr.EnumerateArray())
{
if (alarmFilter(alarm))
{
hasAny = true;
break;
}
}
@hokiepokedad2
Copy link
Copy Markdown
Contributor Author

Independent Review

Reviewed as if seeing this for the first time, walking every code path that creates / updates / clean-toggles an alarm.

Security correctness — clean

All paths gated:

Path Gate
10 alarm controllers [RequireFeatureEnabled(DisableFeatureKeys.X)]
10 alarm services × Create / BulkCreate / Update _featureGate.EnsureEnabledAsync(...) first line ✓
CleaningService.ToggleCleanAsync Resolves type→key via DisableFeatureKeys.ByTrackingType, gates before fetch ✓
ProfileOverviewService.{DuplicateProfile,ImportAlarms}Async EnsureNoDisabledTypesAsync pre-validates entire payload before any state mutation — no half-populated profiles ✓
QuickPickService.ApplyAsync Inherits gates via per-type alarm services ✓
TestAlertController.SendTestAlert Inline check (type is path param) ✓
FeatureDisabledExceptionFilter (global) Catches anything thrown from deeper layers and maps to 403; both paths share FeatureDisabledResponse.Create so wire format can't drift ✓

Architecture

  • IFeatureGate indirection earns its keep — centralizes the inverted-boolean semantics so 10+ callers don't repeat it. Dual API (IsEnabledAsync for boolean checks, EnsureEnabledAsync for control flow) is justified.
  • DisableFeatureKeys dual layout (const string fields + ByTrackingType dict) is a thoughtful typo-prevention pattern.
  • Throwing for control flow is acceptable here — it fires at most once per blocked request, not in tight loops, and the alternative (return-bool + duplicate checks at every call site) recreates the original UI/API mismatch class of bug.

Performance

  • 5-min IMemoryCache on SiteSettingService.GetByKeyAsync with explicit invalidation on writes — without this the dashboard would add ~10 MySQL roundtrips per page load.
  • TOCTOU window documented in code (slow read overlapping a write can leak stale data for up to TTL). Acceptable for admin-rare toggles, self-heals.
  • Cache key prefix site_setting: — collision-safe.

Frontend

  • disabledFeatureGuard on all 9 alarm routes; gracefully allows navigation if loadOnce fails (backend still enforces).
  • 403 interceptor distinguishes feature-disabled (has disableKey) from generic permission-denied — different toast + redirect.
  • ERROR.FEATURE_DISABLED in all 11 locales.
  • Admin no longer sees disabled nav items (the original bug was a UI/API mismatch — leaving one in for admins would have recreated it in miniature).

Test coverage

~30 new tests: FeatureGateTests, RequireFeatureEnabledAttributeTests, FeatureDisabledExceptionFilterTests, per-service disable tests on MonsterService and CleaningService (representative), ProfileOverviewService pre-validation tests, ProfileOverviewController exception-propagation tests, frontend disabled-feature.guard.spec.ts (including loadOnce-fails fallback), 403-with-disableKey case in error.interceptor.spec.ts. All 1013 backend + 627 frontend tests pass.

Backward compat

  • No breaking API surface changes (gates are additive).
  • Existing alarms remain queryable when feature is enabled.
  • disable_eggs deliberately doesn't exist (eggs share raid UI) — documented in EggService and DisableFeatureKeys.
  • disable_fort_changes was missing from SettingsMigrationService (pre-existing oversight) — fixed.

Findings

  • [praise] Defense-in-depth across three layers + unified response contract is the right shape for this bug class.
  • [praise] Pre-validation in ProfileOverviewService prevents half-populated profiles on mid-loop disable.
  • [praise] Audit logging at both controller and service layers gives admins symmetric visibility into blocked attempts.
  • [minor] TestAlertController keeps its own type→key dictionary instead of reusing DisableFeatureKeys.ByTrackingType. Acceptable since the type whitelist is small and validated, but worth deduplicating on next touch.
  • [nit] Per-service disable tests exist only for MonsterService and CleaningService (representative). The other 8 services share the gate via IFeatureGate so the pattern is identical, but one or two more would close the coverage matrix.
  • [nit] No app.spec.ts covering the nav-filter logic itself — disabledFeatureGuard is tested in isolation but the alarmNavItems/settingsNavItems computeds aren't.

Recommendation: APPROVE

No blockers. Security correctness is solid, architecture is clean, perf trade-offs are documented, tests are substantial, docs (CHANGELOG + CLAUDE.md Feature Gating subsection) are thorough. Minor / nit items are low-risk given pattern consistency.

hokiepokedad2 added a commit that referenced this pull request Apr 17, 2026
…p.spec

Three follow-ups from the iteration-4 review on PR #238:

- TestAlertController now reuses `DisableFeatureKeys.ByTrackingType` and
  `IFeatureGate.EnsureEnabledAsync` instead of maintaining its own
  type→key dictionary and calling `ISiteSettingService.GetBoolAsync`
  directly. The throw is mapped to 403 by the existing global
  `FeatureDisabledExceptionFilter` so the wire contract is unchanged.
  Test was updated to match.

- Disable-guard tests added to all 9 remaining alarm services
  (Raid/Egg/Quest/Invasion/Lure/Nest/Gym/MaxBattle/FortChange) so each
  one independently verifies that `CreateAsync` and `BulkCreateAsync`
  short-circuit at `EnsureEnabledAsync` without touching the proxy.
  Previously only `MonsterService` and `CleaningService` had explicit
  per-service tests; the pattern is identical across the 10 services
  but representative coverage left 8 service files silently
  regressable.

- New `src/app/app.spec.ts` covers the `alarmNavItems` /
  `settingsNavItems` filter logic — including the iteration-1 fix that
  removed the admin bypass. Without this spec, "admin sees disabled
  nav items" could regress without breaking any existing test.
@hokiepokedad2
Copy link
Copy Markdown
Contributor Author

Closed all 3 review items in commit da9dddc:

  • [minor → fixed] TestAlertController now reuses DisableFeatureKeys.ByTrackingType and routes through IFeatureGate.EnsureEnabledAsync (the existing global FeatureDisabledExceptionFilter maps the throw to 403). One less duplicated mapping to drift.
  • [nit → fixed] Per-service disable tests added for the 9 remaining alarm services (Raid, Egg, Quest, Invasion, Lure, Nest, Gym, MaxBattle, FortChange). Each verifies CreateAsync and BulkCreateAsync short-circuit at the gate. +18 tests.
  • [nit → fixed] New src/app/app.spec.ts covers alarmNavItems / settingsNavItems filter logic, parameterized over all 9 alarm disable keys, including explicit "admin nav also hides disabled items" cases. +23 tests.

Verification: backend 1031 passing, frontend 650 passing (was 1013 / 627), dotnet format, npm run lint, npm run prettier-check all clean.

The disable_mons / disable_raids / disable_quests / disable_invasions /
disable_lures / disable_nests / disable_gyms / disable_maxbattles /
disable_fort_changes site settings only hid navigation items in the SPA
— every alarm controller was reachable via direct API calls regardless
of the toggle, and several service-to-service paths (quick-pick apply,
profile duplicate/import, cleaning toggle) wrote alarms past the
controller layer entirely.

Defense-in-depth gating across three layers:

- Constants & types: `DisableFeatureKeys` (single source of truth +
  tracking-type→key map), `FeatureDisabledException` carrying the
  violated key.
- Gate service: `IFeatureGate` exposes `IsEnabledAsync` (boolean check)
  and `EnsureEnabledAsync` (throws). Logs at Info for an audit trail.
- Controller layer: `[RequireFeatureEnabled]` on all 10 alarm
  controllers; inline check in `TestAlertController`. Global
  `FeatureDisabledExceptionFilter` maps thrown exceptions to 403. Both
  paths share `FeatureDisabledResponse.Create` so the wire format
  can't drift.
- Service layer: every alarm service's `CreateAsync`,
  `BulkCreateAsync`, and `UpdateAsync` calls `EnsureEnabledAsync`
  first. `CleaningService.ToggleCleanAsync` and
  `ProfileOverviewService.{DuplicateProfile,ImportAlarms}Async`
  pre-validate before mutating state.

Frontend follow-through: `disabledFeatureGuard` on 9 alarm routes
(falls back to allowing navigation if `loadOnce` errors — backend still
enforces); 403 interceptor distinguishes feature-disabled from generic
permission denial via `disableKey` and redirects to `/dashboard`; left
nav hides disabled items for admins too; `ERROR.FEATURE_DISABLED` in
all 11 locales; empty alarm-group label and divider hidden.

Performance: `SiteSettingService.GetByKeyAsync` now wrapped in
`IMemoryCache` with 5-min TTL and explicit invalidation on writes —
otherwise the dashboard would add ~10 MySQL roundtrips per page load.

Operator note: the toggle now means "nobody uses this feature" —
including admins. To use a disabled feature, flip the toggle off in
Admin → Settings. `disable_eggs` doesn't exist; eggs share
`disable_raids` because they share the raid UI.
…p.spec

Three follow-ups from the iteration-4 review on PR #238:

- TestAlertController now reuses `DisableFeatureKeys.ByTrackingType` and
  `IFeatureGate.EnsureEnabledAsync` instead of maintaining its own
  type→key dictionary and calling `ISiteSettingService.GetBoolAsync`
  directly. The throw is mapped to 403 by the existing global
  `FeatureDisabledExceptionFilter` so the wire contract is unchanged.
  Test was updated to match.

- Disable-guard tests added to all 9 remaining alarm services
  (Raid/Egg/Quest/Invasion/Lure/Nest/Gym/MaxBattle/FortChange) so each
  one independently verifies that `CreateAsync` and `BulkCreateAsync`
  short-circuit at `EnsureEnabledAsync` without touching the proxy.
  Previously only `MonsterService` and `CleaningService` had explicit
  per-service tests; the pattern is identical across the 10 services
  but representative coverage left 8 service files silently
  regressable.

- New `src/app/app.spec.ts` covers the `alarmNavItems` /
  `settingsNavItems` filter logic — including the iteration-1 fix that
  removed the admin bypass. Without this spec, "admin sees disabled
  nav items" could regress without breaking any existing test.
@hokiepokedad2 hokiepokedad2 force-pushed the fix/236-server-side-disable-alarm-enforcement branch from da9dddc to 021899b Compare April 17, 2026 18:01
@hokiepokedad2 hokiepokedad2 merged commit f6832e0 into main Apr 17, 2026
6 checks passed
@hokiepokedad2 hokiepokedad2 deleted the fix/236-server-side-disable-alarm-enforcement branch April 17, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Disable alarm type settings are not enforced server-side

1 participant