Central authorization: access control plane for proxied webapps (Phase 1) - #17
Conversation
…handlers, SPA login (WI-2)
Adds the Users module from docs/central-auth/design.md §7: users.list /
create / update / resetPassword / setDisabled / delete, every handler
[RequireRole("Admin")], plus the matching admin screen in the SPA.
Backend (src/Watchtower.Application/Modules/Users):
- UserDto never carries the password hash or the security/concurrency
stamps; LockedOut is derived from LockoutEnd against the clock, because
a lockout lapses and a stored flag would be stale the moment it did.
- Passwords go through UserManager (policy + PBKDF2); resetPassword uses a
reset token, so a policy-violating value leaves the previous password
working instead of clearing it.
- Last-admin guard on demotion, disable and delete — the only refusal in
the module. Self-demotion, self-disable and self-delete stay allowed.
- resetPassword, setDisabled(true) and delete revoke the account's
sessions; re-enabling also clears the brute-force lockout.
- AuthEvent rows for create/update/delete/password reset/disable/enable,
naming the target in Detail because the FK is SET NULL on delete.
Frontend (src/watchtower-web/src/modules/users): gated Users page with the
create/edit/set-password dialogs, enable-disable and delete confirmations,
and RPC failure messages surfaced as toasts.
rpc-schema.json regenerated (70 methods, purely additive).
…ed Caddy sites, JWT (WI-4) Implements docs/central-auth/design.md §5 and §6: Caddy forward-auths every request to a protected app to GET /api/access/verify, which resolves the route by X-Forwarded-Host, honours bypass paths, validates the per-app __wt_access session and answers with a verdict — 200 plus identity headers, a 302 to the central login page for browser navigations, a plain 401 for everything else, or a 403 denial page when a Restricted route holds no grant for the account. The cross-domain hand-over: /api/auth/login gained an optional redirectUri and /api/auth/continue is the same step for a visitor already signed in centrally. Both validate the URL against the route table, check authorization, and mint a single-use 60-second login code; /.watchtower/callback on the app's own domain redeems it into that domain's session, and /.watchtower/logout gives it back. Identity forwarding is a trust boundary (§2.3), so protected site blocks strip X-Watchtower-User/-Email/-Jwt from the inbound request before forward_auth adds the verified ones, and the ES256 assertion binds `aud` to the app's own domain. The key pair is generated on first use, persisted as PEM under Auth:KeyPath, and published as a JWKS at /api/auth/jwks. No schema change: LoginCode and RouteAccessGrant already exist from WI-1.
…ut, callback host check 1. [MAJOR] RouteAccessPolicy.IsExemptPath treated /webhooks/..%2fadmin (and %2F/%5c/%5C, %2e variants) as exempt: the dots stay literal so no segment equals "..", while the encoded separator hides the traversal, so an upstream that decodes %2f→/ and normalises reaches /admin unauthenticated. Any percent-encoding in the matched path now disqualifies the fast-path exemption — a literal ASCII bypass prefix needs no encoding to be matched, so an encoded byte is only ever an attempt to smuggle past the prefix check. 2. [MINOR] CaddyManager.ProjectSites: an explicit Route for Auth:Host set Authenticated/Restricted was emitted behind forward_auth → login-redirect loop, UI reachable only via the published port. The auth-host site is now force-unprotected whether it comes from an explicit row or the synthesised self-route; the explicit row still renders its own upstream. 3. [MINOR] Callback host binding was skipped when X-Forwarded-Host was absent. Caddy always sets it for a request through the app site, so absence now refuses rather than minting a cookie scoped to an unbound host. Tests: encoded-separator vectors asserted EXEMPT=false, a plain path still matches; explicit non-Public auth-host row asserted unprotected; callback without X-Forwarded-Host asserted refused.
… role gating - Everything after an account mutation commits now runs on CancellationToken.None: session revocations, the lockout clear and the audit save. On the request token a caller hanging up mid-request could leave a reset password with its old sessions live, or an administrative action out of the trail. RecordAsync takes no token at all, so the invariant is structural rather than remembered — same mechanism the login endpoints already use. - DeleteUser is delete-then-audit. There is no ambient transaction across the two writes, so auditing first meant a delete that failed on the concurrency stamp left a trail claiming an account was removed while it is still there. Sessions now go through the verified FK cascade instead of an explicit pre-revoke that a failed delete would leave applied. - Last-admin guard stays a pre-check, and its doc no longer overclaims. Evidence: Elarion's TransactionDecorator does roll back on a failed Result, but it is opt-in on three counts this app does not meet — no [DecoratorList], no ICommand-marked requests, and no AddElarionUnitOfWork (the default IUnitOfWork is the no-op InMemoryUnitOfWork). So a failed Result rolls nothing back here and an after-the-write re-check could not close the race. Documented, with the break-glass env var as recovery. - IdentityResult mapping is consistent: a concurrency failure is Conflict in every handler, everything else Validation. Detected by Identity's error code read off IdentityErrorDescriber, never by message text. - setDisabled clears the lockout in the same write as the flag (one write, not three) and its result is checked, so the audit no longer claims lockoutCleared unconditionally. resetPassword checks it too. - Frontend gates the Users module and route on module AND role 'Admin' (design.md §8) by wiring the contributions kit's role axis in vocabulary.ts. No generated code was forked; the backend exports no role catalogue, so RoleName is `string` and that caveat is documented. - AuthEventKinds holds the shared Kind vocabulary, used by both this module and the login endpoints; RouteId is stated explicitly on audit rows. Tests: non-admin denial for all five mutations, and a delete that loses the concurrency race writes no audit row and returns Conflict.
# Conflicts: # src/Watchtower.Api/Endpoints/WatchtowerAuthEndpoints.cs
…erator guide (WI-6)
Follow-up pushed: identity-forwarding redesign (WI-8)Three commits added on top of the Phase 1 work ( What changed
Review: went through an adversarial security review that built real token-confusion attacks ( Verification: build clean, 247 tests (was 208), migrations clean (one new: The reviewer note about a browser smoke-test under |
…-auth-84057b # Conflicts: # README.md # src/Watchtower.Api/Endpoints/WatchtowerHttpEndpoints.cs
Central Authorization — Access Control Plane for Proxied Webapps (Phase 1)
Turns Watchtower into a Cloudflare-Access-style access-control plane for the apps it proxies, while also giving Watchtower's own UI a real login. Opt-in via
WATCHTOWER__AUTH__ENABLED(default off), so existing deployments are unchanged until enabled.Design:
docs/central-auth/design.md· Operator guide:docs/central-auth/README.mdWhat's included
adminbootstrap, and a break-glass recovery path (WATCHTOWER__AUTH__RESETPASSWORD+ published-port fallback).AnonymousCurrentUserplaceholder;[assembly: ElarionAuthorizationDefaults]turns on secure-by-default across every handler, with an implicit-admin fallback that keepsAuth:Enabled=falsebehavior byte-identical to today.users.*handlers ([RequireRole("Admin")]), last-admin guard, session revocation on password reset / disable.AuthEvent), operator guide, and a fix for the previously-vacuous CI schema-freshness gate (now proven to catch drift).Not in this PR (Phase 2, scoped in the design doc)
OIDC/Keycloak upstream, groups, MFA, an audit-viewing UI.
Verification
dotnet build Watchtower.slnx -c Release— clean (0 warnings,TreatWarningsAsErrorson)dotnet test Watchtower.slnx— 208 passed / 0 failed (this feature introduces the repo's first test projects:Watchtower.Application.Tests,Watchtower.Api.Tests)ef migrations has-pending-model-changes— clean; exactly one new migration (AddCentralAuth)~12,200 insertions across 95 files. Every commit went through an independent review loop before merge.
Reviewer notes
Auth:Enabled=true— worth a manual pass./.watchtower/*reserved prefix, verify reachable on the published port).