Skip to content

Unwind Phase 3a service-layer auth, tombstone Phase 3 of auth plan#231

Merged
peterdrier merged 4 commits intomainfrom
revert-phase3-auth
Apr 15, 2026
Merged

Unwind Phase 3a service-layer auth, tombstone Phase 3 of auth plan#231
peterdrier merged 4 commits intomainfrom
revert-phase3-auth

Conversation

@peterdrier
Copy link
Copy Markdown
Owner

Summary

Reverts Phase 3a (nobodies-collective#418) of the first-class authorization transition, tombstones Phase 3 in the plan doc, and removes the service-locator hack that was added to paper over the original crash.

Why

Phase 3 proposed pushing authorization into service methods (services inject IAuthorizationService, service methods take ClaimsPrincipal, jobs pass SystemPrincipal). Track record on Humans: 0-for-2.

Phase Issue / PR What happened
3a nobodies-collective#418 / PR #210 Shipped, broke QA in 2 days with circular DI: TeamService → RoleAssignmentService → IAuthorizationService → TeamAuthorizationHandler → ITeamService. "Fixed" in 225ac14 by making TeamAuthorizationHandler lazily resolve ITeamService via IServiceProvider — hides the cycle from the validator, doesn't remove it.
3b nobodies-collective#419 Merged (1626098), reverted (bbbe508) for the same reason.
3c nobodies-collective#420 Drafted on sprint/20260415/batch-4 but never merged. Closed as won't do on 2026-04-15.

docs/architecture/design-rules.md §11 already says services are auth-free. This PR makes the code match the rule.

Changes

  • RoleAssignmentService: drop IAuthorizationService dependency and the ClaimsPrincipal principal parameter on AssignRoleAsync / EndRoleAsync. Service is auth-free again.
  • IRoleAssignmentService: match signature change.
  • ProfileController: authorize with IAuthorizationService.AuthorizeAsync(User, roleName, RoleAssignmentOperationRequirement.Manage) before calling the service. EndRole fetches the assignment, authorizes against its role name, returns NotFound on deny to prevent enumeration (same behavior as before).
  • RoleAssignmentAuthorizationHandler: remove the SystemPrincipal bypass branch. Handler now only checks Admin and Board/HumanAdmin + BoardManageableRoles.
  • SystemPrincipal: deleted. No remaining callers.
  • RoleAssignmentClaimsTransformation: drop the defensive strip of SystemPrincipal claims — the claim no longer grants anything.
  • TeamAuthorizationHandler: revert 225ac14. Take ITeamService as a normal constructor dependency. The cycle is gone because RoleAssignmentService no longer injects IAuthorizationService, so there is no path back into TeamAuthorizationHandler from a service.
  • Plan doc (docs/plans/2026-04-03-first-class-authorization-transition.md): tombstone Phase 3 with full rationale; update sequencing and issue chain sections to reflect cancellation.
  • Tests: drop auth-denial and SystemPrincipal tests that were testing the removed code path. The deny cases are already covered at the correct layer (RoleAssignmentAuthorizationHandlerTests). TeamServiceTests and TeamRoleServiceTests updated for the RoleAssignmentService ctor signature change.

Test plan

  • dotnet build Humans.slnx — clean, 0 errors, 0 warnings
  • Humans.Domain.Tests — 108/108 passing
  • Humans.Application.Tests — 941/941 passing (includes RoleAssignmentService, RoleAssignmentAuthorizationHandler, TeamAuthorizationHandler, TeamService, TeamRoleService)
  • Humans.Integration.Tests — not run locally (Docker daemon not available); will run on CI
  • Smoke test on QA: add/end a role from /Profile/{id}/Admin/Roles; verify Admin can, Board can for BoardManageableRoles, regular user gets Forbid/NotFound
  • Smoke test Teams section after deploy to verify TeamAuthorizationHandler DI still resolves cleanly (previously crashed here)

🤖 Generated with Claude Code

peterdrier and others added 4 commits April 15, 2026 16:34
The old rules said "services own DB access" and meant "services inject
DbContext directly." That conflated business logic with persistence,
made cross-domain joins impossible to forbid structurally, and left
caching as an inline IMemoryCache concern scattered through service
methods. The rewrite updates the target to:

- Services live in Humans.Application (not Infrastructure) so they
  physically cannot import EntityFrameworkCore — enforced by the
  project reference graph, not by convention.
- Every domain has a narrow IProfileRepository-style interface in
  Application, with an EF-backed implementation in Infrastructure.
  Entities in, entities out; no IQueryable, no cross-domain signatures,
  no Includes that cross domains.
- Every cached domain has an IProfileStore — a dict-backed in-memory
  entity cache with a single writer (the owning service), warmed on
  startup. Replaces inline IMemoryCache.GetOrCreateAsync calls.
- Caching is applied via a Scrutor decorator (CachingProfileService)
  wrapping the service, not by mixing cache logic into the service
  itself. One crosscut, one wrapper, zero business logic in the
  decorator.
- Cross-domain EF joins are forbidden. When a caller needs Profile +
  Team + User together, it asks each owning service and stitches in
  memory via GetByIdsAsync. 48 existing cross-domain .Include() calls
  across 20 services are the migration blast radius.
- Decorators are only for mechanical, context-free crosscuts (caching,
  metrics, retry, access logging). Domain audit stays in-service
  because it needs actor, before/after, semantic intent, and same-
  unit-of-work guarantees.
- Migration is per-domain, starting with a Profile spike, then
  quarantining each domain one at a time in priority order. Concrete
  known-violation counts included so progress is measurable.

Keep sections unchanged: Table Ownership Map, Cross-Cutting Services,
Authorization Pattern, Immutable Entity Rules, Google Resource
Ownership. These were already aligned with the target.

Follow-ups not in this commit: update dependency-graph.md with the
target graph, update coding-rules.md and code-review-rules.md with
grep-able enforcement rules, update CLAUDE.md's Critical: Design Rules
bullet list. These land in separate commits so the diffs stay readable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pure rename. Content is unchanged in this commit; the trim to remove
persistence/service-layer content that conflicts with design-rules.md
lands in the next commit so git rename detection preserves history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove ~270 lines of persistence/service-layer content that conflicted
with design-rules.md (§1 Current Shape, §2-§7 Core Decisions, Layer
Rules, Read Rules, Write Rules, Caching Rules, Direction of Travel).
The persistence/service doctrine now lives exclusively in design-rules.md.

Keep the cross-cutting content that has no better home:
- Domain Owns Local Invariants
- Transaction Boundary
- Caching (controller-level rules only, with pointer to design-rules §4-§5)
- Authorization (web/service rules only, with pointer to design-rules §11)
- Integration (no vendor types leaking, stub impls over env checks)
- Time and Configuration (IClock, NodaTime, centralized config)
- Rendering (Razor default, fetch() exceptions with per-file list)
- Testing (service-boundary first, preferred test order)
- Exception Rule (how to justify breaking a default)
- Smell Checklist (now reorganized and expanded with the new smells:
  services in Infrastructure/Services/, direct DbContext in services,
  cross-domain .Include, IQueryable-returning repositories, inline
  IMemoryCache, cross-domain nav properties, audit-as-decorator)

The Exception Rule gains one weak-reason bullet ("adding a repository
felt like over-engineering") so the old anti-repository position is
explicitly retired rather than left as silent drift.

docs/README.md: update the Architecture section from the outdated
"Infrastructure owns services" layer map to the new layering, and
replace the single link to architecture.md with a full index of the
nine docs now living in docs/architecture/ (design rules, conventions,
dependency graph, data model, coding rules, code review rules, service
data access map, code analysis, maintenance log).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 3 of docs/plans/2026-04-03-first-class-authorization-transition.md
proposed pushing authorization into service methods. The track record on
this codebase is 0-for-2:

- Phase 3a (nobodies-collective#418, PR #210, role assignment) shipped and broke QA within
  two days with circular DI:
    TeamService -> RoleAssignmentService -> IAuthorizationService
                -> TeamAuthorizationHandler -> ITeamService
  "Fixed" in 225ac14 by making TeamAuthorizationHandler lazily resolve
  ITeamService via IServiceProvider -- a service-locator escape hatch
  that hides the cycle from the DI validator instead of removing it.

- Phase 3b (nobodies-collective#419, Google sync) was merged (1626098) and reverted
  (bbbe508) for the same reason.

- Phase 3c (nobodies-collective#420, budget mutations) was drafted but never merged.
  Closed as "won't do" on 2026-04-15.

design-rules.md section 11 already says services are auth-free. This PR
makes the code match the rule.

Changes:
- RoleAssignmentService: drop IAuthorizationService dependency and the
  ClaimsPrincipal parameter on AssignRoleAsync / EndRoleAsync. Service
  is auth-free again.
- IRoleAssignmentService: match signature change.
- ProfileController: authorize with IAuthorizationService.AuthorizeAsync
  and RoleAssignmentOperationRequirement.Manage before calling the
  service. For EndRole, fetch the assignment, authorize on its role
  name, return NotFound on deny (prevents enumeration).
- RoleAssignmentAuthorizationHandler: remove the SystemPrincipal bypass
  branch; handler now only checks Admin and Board/HumanAdmin+
  BoardManageableRoles.
- SystemPrincipal: deleted. No remaining callers.
- RoleAssignmentClaimsTransformation: drop the defensive strip of
  SystemPrincipal claims (the claim no longer grants anything).
- TeamAuthorizationHandler: revert 225ac14. Take ITeamService as a
  normal constructor dependency. The cycle is gone because
  RoleAssignmentService no longer injects IAuthorizationService, so
  there is no path back into TeamAuthorizationHandler from a service.
- Plan doc: tombstone Phase 3 with full rationale; update sequencing
  and issue chain sections to reflect cancellation.
- Tests: drop auth-denial and SystemPrincipal tests that were testing
  the removed code path; the handler tests already cover the deny
  cases at the correct layer. TeamServiceTests and TeamRoleServiceTests
  updated for the RoleAssignmentService ctor signature change.

Verified: dotnet build clean (0 errors), 941 Application tests pass,
108 Domain tests pass. Integration tests not run -- Docker daemon not
available locally, unrelated to this change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@peterdrier peterdrier merged commit d8cbd4f into main Apr 15, 2026
5 checks passed
@peterdrier peterdrier deleted the revert-phase3-auth branch April 15, 2026 21:08
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.

1 participant