Skip to content

feat: Policy classes for entity-level authorization (#162)#257

Merged
antosubash merged 8 commits into
mainfrom
feature/issue-162-policy-authorization
Jun 10, 2026
Merged

feat: Policy classes for entity-level authorization (#162)#257
antosubash merged 8 commits into
mainfrom
feature/issue-162-policy-authorization

Conversation

@antosubash

@antosubash antosubash commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Closes #162.

Adds Laravel-style policy classes — instance-level authorization layered on top of the existing string-permission system — then rolls the mechanism out across the modules that benefit and fixes the authorization gaps the rollout surfaced.

The mechanism (SimpleModule.Core.Authorization.Policies)

  • IPolicy<TResource> — per-resource verbs (view/update/delete + custom) returning AuthorizationResult.Allow() / .Deny(reason) / .DenyAsNotFound(reason).
  • IAuthorizerCheckAsync (returns result) and AuthorizeAsync (throws on deny). Policies run in registration order, first deny wins; no registered policy fails closed with MissingPolicyException.
  • Two enforcement styles: declarative .AuthorizeResource<T>("action") endpoint filter (backed by IResourceResolver<T>) for handlers that don't otherwise need the entity, and imperative IAuthorizer (load → authorize → act) for handlers that do.
  • 403 vs 404: denials throw ForbiddenException (403); DenyAsNotFound() throws NotFoundException (404) for anti-enumeration. Per-decision; a host-level PolicyAuthorizationOptions.NotFoundActions override also exists (empty by default).
  • Source generator: auto-discovers IPolicy<T> implementors (impl, contracts, and host assemblies, incl. nested classes) and registers them with TryAddEnumerable. Four diagnostics: SM0058 (resource must be a contracts DTO), SM0059 (policy must be public), SM0060 (policy owned by the resource's module), SM0061 (policy must not be generic) — all waived for [ManualContractRegistration] where applicable.

Reference implementations (3 shipped policies)

Module Policy Rule Form
Notifications NotificationPolicy recipient-only mark-read declarative AuthorizeResource
FileStorage FileStoragePolicy uploader-or-admin view/delete imperative IAuthorizer
Users UserPolicy self-or-admin view/update, admin-only delete imperative IAuthorizer

Security fixes found during the module survey

  • Users IDOR (broken object-level authorization)GET/PUT/DELETE /api/users/{id} previously had only .RequireAuthorization(): any authenticated user could read, edit, or delete any account. Added the missing RequirePermission(View/Update/Delete) gate plus UserPolicy.
  • FileStorage — three endpoints duplicated an inline ownership check over an unscoped by-id contract (fragile). Centralized into FileStoragePolicy; non-owner is now 404 (was 403).
  • Settings (coarse-permission gaps, not policy work)GET /api/settings now requires Settings.View and no longer leaks other users' User-scoped values via ?scope=User; menu-mutation endpoints + GetAvailablePages now require ManageMenus (the public menu tree stays open to any authenticated user).

Deliberately not changed

  • Sessions (OpenIddict/Keycloak) — evaluated for a SessionPolicy and declined: SessionDto has no owner field, the "can't revoke your current session" rule is domain state already modeled by RevokeSessionResult, and the duplication is two ISessionContracts implementations over different backends that no IPolicy<SessionDto> can unify. The typed-result service method is the correct abstraction.
  • All other modules (Admin, Permissions, Identity, Keycloak, BackgroundJobs, RateLimiting, FeatureFlags, AuditLogs, Email, Localization, Dashboard, Tenants) are correctly served by coarse permissions.

Acceptance criteria (#162)

  • IPolicy<T> and IAuthorizer in SimpleModule.Core
  • Source-generator discovery + diagnostics (SM0058–SM0061)
  • xUnit tests for resolution, missing policy, multi-policy precedence, declarative filter, 403/404 mapping
  • Docs page with worked example (docs/site/guide/policies.md) + CONSTITUTION section
  • Existing modules refactored as references (Notifications, FileStorage, Users)

Review history & verification

  • Iterated /code-review to convergence over 4 hardening rounds (round 5 returned no findings); ran a full /qa browser cycle (found + fixed the inbox 204 mark-read bug).
  • Build clean under TreatWarningsAsErrors; all 17 test suites green (Core 268, Generator 219 incl. policy-discovery/diagnostic tests, +21 new module authorization tests).
  • Browser-verified the live matrix as a regular user: own file 200 / others' file 404 / self 200 / other user 404 / cross-user update 403 / non-admin delete 403.

7 commits: feat → 4 review-hardening rounds → QA fix → multi-module rollout.

…ons (#162)

- IPolicy<TResource>, AuthorizationResult, IAuthorizer/Authorizer in
  SimpleModule.Core.Authorization.Policies: instance-level rules
  (ownership, tenancy, state) on top of string permissions; deny wins
  across multiple policies, missing policy fails closed with
  MissingPolicyException
- PolicyAuthorizationOptions.NotFoundActions maps denials to 404
  (default: view) to prevent resource enumeration
- Declarative .AuthorizeResource<T>(action) endpoint filter backed by
  IResourceResolver<T>
- Source generator discovers IPolicy<T> implementors and emits scoped
  registrations in AddModules(); new SM0058 diagnostic requires policy
  resource types to be contracts DTOs
- Notifications refactored as the reference implementation:
  NotificationPolicy (owner-or-admin), MarkReadEndpoint now does
  load -> authorize -> act via IAuthorizer
- Docs: guide/policies.md, permissions cross-link, CONSTITUTION section
  and diagnostics table

Closes #162
Addresses all 10 findings from the high-effort review of the IPolicy<T>
feature:

- Restore owner-scoped MarkReadAsync(id, userId) on INotificationsContracts;
  the unscoped FindAsync moves to module-internal INotificationStore so
  in-process callers can't bypass ownership (also restores 404 when the
  row vanishes between load and write)
- SM0059 (Error): policy classes must be public — non-public policies were
  silently skipped at registration and failed only at runtime
- SM0058 reworked: resource classified symbolically at discovery ([Dto] or
  .Contracts assembly), fixing false positives for [NoDtoGeneration]/IEvent
  contracts entities
- PolicyFinder now scans contracts assemblies and nested types, matching
  the documented auto-discovery contract
- AuthorizationResult.DenyAsNotFound(): per-decision 404 mapping; the
  Notifications module no longer mutates host-global
  PolicyAuthorizationOptions (action-string collision across modules)
- AddSimpleModuleWorker registers IAuthorizer + options so background
  handlers can authorize resources like the web host
- Generated registrations use TryAddEnumerable — manual registration can
  no longer cause a policy to execute twice per check
- NotificationPolicy is owner-only again: admins are not exempt from
  instance rules (pre-refactor behavior), covered by an integration test
- AuthorizeResource: missing route value now throws InvalidOperationException
  (loud misconfig) instead of 404; 6 new TestServer-based filter tests
- SM0060 (Error): policies must be owned by the resource's module —
  deny-wins semantics made foreign policies a cross-module veto
@antosubash

Copy link
Copy Markdown
Owner Author

Pushed a hardening commit addressing all 10 findings from the high-effort code review:

Security / correctness

  1. INotificationsContracts is owner-scoped again (MarkReadAsync(id, userId)bool); the unscoped FindAsync moved to module-internal INotificationStore, so in-process callers can't bypass ownership. Also restores 404 when the row vanishes between load and write.
  2. SM0059 (Error): policy classes must be public — previously an internal policy compiled green, was silently skipped at registration, and failed only at runtime.
  3. SM0058 reworked: resource validity is determined symbolically at discovery ([Dto] or .Contracts assembly), fixing false positives for [NoDtoGeneration]/IEvent contracts entities like SettingEntity.
  4. PolicyFinder now scans contracts assemblies and nested types, matching the documented auto-discovery contract.
  5. SM0060 (Error): a policy must be owned by its resource's module — deny-wins semantics otherwise let a foreign module veto another module's authorization decisions.

Design / robustness
6. AuthorizationResult.DenyAsNotFound() — per-decision 404 mapping; NotificationsModule no longer mutates host-global PolicyAuthorizationOptions (cross-module action-string collision).
7. AddSimpleModuleWorker registers IAuthorizer + options, so background handlers resolve the same DI graph as the web host.
8. Generated registrations use TryAddEnumerable — a manually-registered policy can no longer execute twice per check.
9. NotificationPolicy is owner-only again (admins are not exempt from instance rules — pre-refactor behavior), with an integration test covering the admin case.
10. AuthorizeResource: a missing route value now throws InvalidOperationException (loud misconfiguration) instead of a silent universal 404, and the filter has 6 new TestServer-based tests.

Verification: full build clean under TreatWarningsAsErrors; Core 267 ✓ (+14), Generator 215 ✓ (incl. SM0059/SM0060 + multi-assembly/nested-discovery tests), Notifications 25 ✓, and all other module suites green.

- Discovery completeness: PolicyFinder now scans the compiling (host)
  assembly and ALL contracts assemblies (mapped or not), visiting each
  assembly once — host-level policies were silently never registered
- SM0059 uses effective accessibility (containing-type chain); internal
  resource types now fail SM0058 instead of emitting uncompilable code
- SM0061 (Error): generic policy classes are rejected — they previously
  emitted unbound type parameters into AddModules()
- Factory-registration gap: emitted comment and docs now state the real
  TryAddEnumerable semantics (type-based dedup; two-generic factory
  overload), and [ManualContractRegistration] opts a policy out of
  auto-registration entirely
- PolicyAuthorizationOptions.NotFoundActions defaults to empty: the
  host-global action set silently converted explicit Deny(reason) into
  bare 404s; DenyAsNotFound is the per-decision mechanism
- AuthorizeResource: template-aware absence handling — an optional or
  catch-all parameter omitted at runtime is 404, a parameter missing
  from the template is a loud InvalidOperationException
- '.Contracts' suffix via AssemblyConventions with Ordinal comparison,
  matching ContractFinder (case-variant assemblies could silently skip
  the SM0060 ownership check)
- SM0059/SM0061 deduped per class for multi-interface policies
- AuthorizeResourceFilterTests: environment pinned to Production
  (5/6 tests failed under ASPNETCORE_ENVIRONMENT=Development via the
  developer exception page) and shared IClassFixture host
- Notifications reference now exercises the declarative form:
  NotificationResolver + .AuthorizeResource<Notification>(MarkRead);
  MarkReadAsync is a single owner-scoped ExecuteUpdateAsync;
  CancellationToken propagated through FindAsync
- CreateMultiAssemblyCompilation promoted to GeneratorTestHelper;
  Authorizer fail-closed guard moved before the policy loop
- Revert MarkReadAsync to tracked load-modify-save: the round-2
  ExecuteUpdateAsync rewrite silently bypassed the SaveChanges
  interceptor pipeline (audit log entries, UpdatedAt, concurrency stamp
  rotation) — comment now pins the reason
- [ManualContractRegistration] policies are exempt from SM0059/SM0061
  (the module registers them itself, internal/generic is fine), matching
  the attribute's contract-side SM0028 semantics; attribute XML doc and
  a generator test cover the policy opt-out
- IsContractsDto suffix match back to OrdinalIgnoreCase, agreeing with
  SymbolDiscovery's contracts classification (round-2 Ordinal tightening
  disagreed with the rest of the pipeline)
- PolicyFinder walk refactored around a PolicyScanContext struct —
  removes the 7-param threading and the adjacent same-typed dictionary
  swap hazard
- Authorizer back to the zero-alloc fail-closed flag (GetServices is
  already materialized; ToList was a pure copy per guarded request)
- GeneratorTestHelper.CreateMultiAssemblyCompilation host side composed
  from CreateCompilation (one reference list); ContractAutoDiscoveryTests
  and DtoConventionTests private copies now delegate to it
- AuthorizeResource filter test types moved to namespace scope (fixture
  no longer reaches into the test class)
- Docs: SM0060 host-assembly exemption documented (CONSTITUTION +
  guide); FormRequest-before-AuthorizeResource ordering note; stale
  NotFoundActions default claims fixed in tasks/todo.md
…e rules, doc accuracy

- MarkReadAsync treats DbUpdateConcurrencyException as idempotent
  success: concurrent duplicate mark-read (double-click, client retry)
  rotated the concurrency stamp and surfaced as a 500; the caller's
  desired outcome already holds
- Manually-registered generic policies with CONCRETE resource types are
  no longer exempt from SM0058/SM0060 (new ResourceIsTypeParameter flag
  gates the noise suppression precisely; the attribute's documented
  'resource rules still apply' guarantee now holds)
- Docs: SM0060 exemption correctly described as 'assemblies that map to
  no module' rather than 'the host assembly' (a host with its own
  [Module] is not exempt); SM0059/SM0061 manual-registration waiver
  surfaced in the guide and rules table; stale ExecuteUpdateAsync /
  guard-first / AddScoped claims in tasks/todo.md corrected
…uter

The Inbox 'Mark read' / 'Mark all read' buttons called the plain JSON API
endpoints via Inertia's router.post(), which expects an Inertia-protocol
response and treats the endpoints' 204 No Content as 'Server error (204)' —
showing an error toast and never refreshing the list even though the backend
succeeded. Switch to fetch() + router.reload({ only: [...] }), matching the
convention used by the FileStorage/RateLimiting/Email pages. Genuine non-2xx
responses (e.g. a policy 404) are logged and skip the reload.

Surfaced by QA of the issue #162 mark-read flow; pre-existing (Inbox.tsx was
not touched by the policy work and the endpoint already returned 204).
Rolls out the issue #162 IPolicy<T> mechanism to the places the module
survey found, and fixes adjacent coarse-permission gaps.

FileStorage (refactor + hardening):
- FileStoragePolicy : IPolicy<StoredFile> (uploader-or-admin; DenyAsNotFound)
- GetById/Download/Delete now load -> authorize via IAuthorizer; removes the
  duplicated FileOwnershipCheck and the fragility that the by-id contract is
  unscoped. Non-owner now 404 (was 403) for anti-enumeration.

Users (closes a real IDOR / broken object-level authorization):
- GET/PUT/DELETE /api/users/{id} previously had only .RequireAuthorization() —
  any authenticated user could read/update/delete any account. Added the
  missing .RequirePermission(View/Update/Delete) gate plus UserPolicy
  (self-or-admin view/update, admin-only delete; view denials 404).

Settings (coarse gaps found during the survey, not policy work):
- GET /api/settings now requires Settings.View and no longer returns other
  users' User-scoped values (info-disclosure via ?scope=User).
- Menu mutation endpoints + GetAvailablePages now require ManageMenus
  (were .RequireAuthorization() only); the public menu tree stays open to any
  authenticated user.

Sessions: evaluated for a SessionPolicy and deliberately left as-is — SessionDto
has no owner field, the 'block current session' rule is domain state already
modeled by RevokeSessionResult, and the duplication is two ISessionContracts
impls over different stores that a policy cannot unify.

Tests: +4 FileStorage, +8 Users, +5 Settings policy/authorization tests; all
17 suites green. Browser-verified the full matrix (own 200 / others 404 /
self 200 / cross-update 403 / non-admin delete 403).
@antosubash antosubash merged commit 05f792f into main Jun 10, 2026
11 checks passed
@antosubash antosubash deleted the feature/issue-162-policy-authorization branch June 10, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Policy classes for entity-level authorization (layer over Permissions)

1 participant