Skip to content

refactor(admin): operator-only admin + change-management framework#35

Merged
ItsThompson merged 70 commits into
mainfrom
refactor/admin-account
Jul 6, 2026
Merged

refactor(admin): operator-only admin + change-management framework#35
ItsThompson merged 70 commits into
mainfrom
refactor/admin-account

Conversation

@ItsThompson

@ItsThompson ItsThompson commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Converts the GoFin admin account from a superset-of-user into an operator-only identity, makes gateway access control a single source of truth shared by every service, and introduces the reusable change-management framework plus the one-time data cleanup this refactor requires.

Delivers the "Admin Operator Refactor" epic (tickets #1#15) together with the PR review remediation (#17). The terminal operational step (running the 001 cleanup on prod) is intentionally not in this PR: it is a change-managed manual procedure performed after this PR is merged and deployed, finalized by a separate completion PR.

What changed

Backend: a shared access module is the single source of truth (services/access)

  • New gin-free, dependency-free Go module owns the whole gateway-facing route surface: a Registry of every concrete route (41 today) with a mandatory access level, a gin-priority path Resolve, a generic bind-by-ID BindRoutes, a bidirectional VerifyRegistration, and a ProxyPrefixes inventory.
  • Every downstream service (auth/finance/expense/datarights) registers its routes by binding handlers by ID from the Registry. A Registry entry with no handler (or a handler with no entry) fails fast at startup and in a per-service coverage test, so a route can never be served without being classified.
  • The gateway derives both its authz policy and its reverse-proxy wiring from the same module: AccessControl resolves each request against the Registry (via an injected GatewayResolve that classifies gateway-native /health + /metrics as Public), and the proxy groups are generated from ProxyPrefixes. Onboarding a service is a single edit to the shared module.
  • Deny-by-default (security-posture change): an unclassified path resolves to a new Deny level and is refused with 403 before any token is read, replacing the earlier Authenticated fail-safe default. A cross-check test proves every proxied prefix has at least one classified route and vice versa, so no real route is silently denied. Access outcomes for every existing real route are preserved (the gateway AccessControl role matrix is unchanged).
  • The former per-request auth + per-group admin guards (middleware/auth.go, middleware/admin.go) and their tests are deleted; a single AccessControl middleware replaces them and TokenValidator lives in the access package. Direct admin (role=admin) gets 403 on Personal routes; assumed sessions (role=user + assumedBy) pass unchanged.

Frontend: route-metadata guards + operator-only role model

  • Role truth centralized in core/roles.ts (canUseFinanceFeatures, canUseAdminFeatures, getLandingPath).
  • Each route module declares its access via typed handle metadata (accessHandle("personal" | "admin" | ...), checked against RouteAccess at the call site); the auth-layout guard derives behavior from the deepest matched handle.access through one symmetric predicate, canAccess. This deletes the ad-hoc FINANCE_ROUTES array and the hasCompletedOnboarding redirect hack.
  • Symmetric 403: a direct admin opening a personal route by URL and a regular user opening an admin route both render an in-chrome <Forbidden/> page (no silent redirect, no flash of the wrong UI). Onboarding is role-driven, so admins are never routed through it; assumed sessions behave as users.
  • auth-layout.tsx decomposed into features/shell-layout/ (Navbar, DesktopNav, MobileNav, ReturnToAdminButton, LogExpenseFab, Forbidden, the useAuthLayoutGuards hook, and navLinksFor), leaving a thin sub-300-LOC orchestrator; one component per file, named exports.
  • Auth shell tests migrated to userEvent and rewritten for the 403 guard model; new unit coverage for canAccess and navLinksFor.

Change-management framework (change-management/)

  • 000-template, a Python validator (validate_change_management.py), an execution-log generator (generate_execution_log.py), a validate-change-management CI gate, and a project skill.

Admin finance cleanup item 001

  • Authored 001_admin-finance-cleanup (description.md/preflight.md/steps.md + dry-run.sql/cleanup.sql) and a temporary safety test (services/dbmigrate/admin_finance_cleanup_test.go) proving the SQL is scoped + idempotent. The safety test is removed by the post-run completion PR.

Documentation

  • auth.md, data-model.md, api.md, architecture.md, README.md, development.md, testing.md aligned with the operator-only model, the shared registry, and deny-by-default.

Sequencing (why merge + deploy gate the cleanup)

Once this PR is merged and deployed, a direct admin can no longer create finance rows. Only then is the 001 prod runbook run (dry-run → backup → cleanup.sql in a transaction → post-check), followed by the completion PR that removes the safety test, adds the filled execution-log.md, and renames the item folder to 001_admin-finance-cleanup_completed.

Test / validation

  • Backend: new services/access registry/resolver/coverage suites plus the preserved gateway AccessControl role matrix; each module builds under GOWORK=off, and all 5 consumer Dockerfiles copy the shared module (exercised by the CI e2e docker compose up -d --build).
  • Frontend: shell test suite green (112 tests); tsc at the pre-existing ~24-error baseline in @gofin/finance/@gofin/admin (no regression).
  • python3 change-management/.validation/validate_change_management.pyOK: 2 change-management item(s) conform.

Add canUseFinanceFeatures, canUseAdminFeatures, and getLandingPath as the
single source of frontend role truth. Pure, synchronous, framework-free
helpers typed against the core User type, re-exported from the core barrel.
Assert the full two-role x three-helper truth table for the role helpers
using a local User fixture (core is dependency-free and cannot import the
shared test-utils factory without a circular dependency).
- add change-management/ with .validation/, .tools/, and 000-template/assets/
- keep the otherwise-empty dirs tracked via .gitkeep files
- establish the foundation for the validator, log generator, and 001 item
- carry the fixed Change Event / Impact-Risk / Worst Case / Rollback headings
- match section 6 exactly so items pass the validator delivered in #10
- guide authors with per-prompt angle-bracket placeholders
- demonstrate the paired # Activity N / # Validation N structure
- give every block a Description line, Checklist, and Rollback Plan section
- ship two matched pairs each to show the contiguous indexing convention
Add the 001 change-management item's dry-run and cleanup SQL:
- dry-run.sql: read-only per-table count of admin-owned rows
- cleanup.sql: single transaction deleting admin-owned rows from four
  finance.* tables and datarights.export_jobs, scoped and idempotent

Keeps auth.users admin rows and datarights.deletion_jobs audit data.
- replace the em-dash in each 000-template H1 with a colon
- comply with the repo prohibition on em-dashes in tracked files
- no validator impact: title lines are not part of the enforced structure
…tion

ensureSchema opens a plain lib/pq connection to CREATE SCHEMA before
golang-migrate runs. It stripped search_path but left golang-migrate
driver params (the x-* family, e.g. x-migrations-table), which lib/pq
forwards to Postgres as runtime GUCs, causing 'unrecognized configuration
parameter' errors. Extract rawConnectionURL to strip search_path and all
x-* params, and unit-test it.
Add a temporary, TEST_DATABASE_URL-gated integration test that proves the
shipping cleanup.sql is correctly scoped and idempotent before it runs on
prod. Runs auth/finance/datarights migrations (per-service search_path +
distinct x-migrations-table), seeds admin + regular-user finance rows,
executes the exact assets/cleanup.sql, and asserts admin rows deleted,
regular-user rows intact, auth.users admin + datarights.deletion_jobs
audit data preserved, and a second run is a no-op. Removed by the 001
completion PR.
Replace both hardcoded /dashboard navigation targets in useLoginForm
with getLandingPath(user) from @gofin/core: the already-authenticated
redirect effect and the login success handler's final else branch.
Admins land on /admin; regular-user flow (onboarding, returnTo,
/dashboard) is unchanged.
Add /admin route to the shared login render helper and cover admin ->
/admin in both the success handler and the already-authenticated
redirect, alongside the existing user -> /dashboard and valid-returnTo
cases.
Lift the tab-to-section mapping into a role-keyed settingsTabs.tsx so
SettingsFeature is a thin renderer. Admins get [Profile, Password] only;
the finance-only sections (DefaultBudget, Tags, ExportData) are referenced
solely by the user tab definitions. The default tab is now tabList[0].id
(Profile for admins) instead of a hardcoded "budget".
Enumerate every known gateway route class and assert each resolves to an
explicit, rule-classified access level rather than the fail-safe Default.
Distinguishes rule-classified from default fall-through via a twin-policy
technique (resolve under two policies differing only in Default), so
Authenticated routes in the mixed /api/auth group are guarded too, not just
non-default levels. Covers US-GW-09.
Add settingsTabs unit tests (exact admin/user tab lists, role selection,
default tab) and admin render tests asserting Profile+Password only, the
Profile default tab, absence of Budget/Tags/Export sections, and that no
finance API calls fire on the admin render path.
Define the canonical TokenValidator interface and TokenValidationResult
struct in the access package, their consumer being access.AccessControl.
middleware keeps compiling via type aliases (removed with auth.go in the
router-cutover ticket). The concrete gRPC client satisfies access.TokenValidator
structurally, since middleware.TokenValidationResult is now an alias.
Add a direct-admin redirect guard to auth-layout that precedes the onboarding guard: an operator (canUseAdminFeatures && !isAssuming) on any FINANCE_ROUTES path is redirected to /admin. Derive navLinks from role (Admin+Settings for a direct admin, full finance nav otherwise), gate the Log Expense FAB off for direct admins, and target the logo at getLandingPath(user). Role decisions read through the core helpers, not bare role checks.
…session

Replace the old admin-nav-at-/dashboard test (behavior changed: admins are now redirected off finance routes) with coverage for the direct-admin guard redirect, its precedence over the onboarding guard, guard skip while assuming, operator-only nav (Admin+Settings, no FAB, logo->/admin), and the assumed-session nav (full finance nav + Return to Admin, logo->/dashboard).
Matrix over {Public, Authenticated, Personal, Admin} x {no-token, user, admin,
assumed-user} with a fake TokenValidator, plus focused tests for header
stripping, Public validation skip, downstream identity injection, X-Assumed-By
forwarding, assumed-session reachability, and 401/403 warn logging.
- add validate_change_management.py enforcing folder-name, required
  files, description headings, activity/validation pairing, and
  completion-evidence rules per framework section 6
- validate one item (--item) or all items (default); exit non-zero
  with item/file/rule on the first violation per item
- add unittest suite building fixtures in temp dirs (no malformed
  items committed) for every check and CLI exit code
- assert the shipped 000-template and all four status suffixes pass
- Add exit code 2 (usage error) to the Running the Validator section so
  the skill matches the shipped validator, which returns 2 when --root or
  --item does not exist (0/1 were the only codes previously documented).
- Follow-up flagged in the #10/#12 cross-reviews.
- Add hasPathPrefix so a prefix rule matches only on a path-segment boundary (next char is '/' or end), not as a bare substring
- Prevents a future sibling like /api/datarights/exports-admin from matching the Personal prefix /api/datarights/exports and being under-restricted; this resolver is the single authz gate
- Extend resolver tests with boundary cases and a focused segment-boundary test; bare-group paths like /api/finance still match
- Reword rejectForbidden comment: the FORBIDDEN code is preserved but the human-facing message text was consolidated to a generic 'Access denied'
- Prevents the comment from overstating that the full 403 contract is unchanged
- Rename the login mutation onSuccess parameter from user to loggedInUser
- The param is the fresh awaited login result; naming it user shadowed the store user used in the adjacent redirect effect
- Assert an admin with a valid non-finance returnTo lands there, not on the /admin landing path
- Locks the onSuccess-wins timing that this refactor touched (previously uncovered)
- Resolve activeDefinition to tabList[0] when a persisted activeTab falls outside the role-derived tab list, instead of rendering nothing
- Guards the role-flip-without-remount case (auth store re-renders with a new user via onUserUpdated); removes the silent optional-chaining no-render
- Add a discriminating test that flips role on the same instance
Comment thread docs/data-model.md Outdated
Comment thread docs/data-model.md Outdated
Comment thread docs/development.md Outdated
Comment thread docs/testing.md Outdated
Comment thread frontend/apps/shell/app/features/auth/__tests__/login.test.tsx Outdated
Comment thread frontend/apps/shell/app/routes/auth-layout.tsx
Comment thread frontend/apps/shell/app/routes/auth-layout.tsx Outdated
Comment thread services/gateway/internal/access/coverage_test.go Outdated
Comment thread services/gateway/internal/access/resolver_test.go Outdated
- Introduce services/access, a gin-free module owning the Access enum, a
  single Registry of all 41 gateway-facing routes with mandatory access
  levels, and a gin-priority path resolver
- Resolve matches concrete request paths against Registry patterns and
  breaks overlaps (e.g. /api/expenses/suggestions vs /:id) the way gin
  dispatches, carrying forward ca37e4c's segment-precise intent
- Register ./access in go.work so the CI module loop runs its tests
- Depend on services/access (require + replace) and inject GatewayResolve
  into AccessControl instead of a local Policy; /health and /metrics stay
  gateway-classified Public, everything else delegates to access.Resolve
- Delete the moved/superseded access.go, policy.go, resolver.go and their
  duplicated resolver_test.go and hand-maintained coverage_test.go
- Rewrite the fail-safe test around an injected out-of-enum resolver and add
  a GatewayResolve test; add the access module COPY lines to the Dockerfile
- Point the direct-admin rejection test at the real GET /api/expenses route
  (the precise resolver sends the non-real trailing-slash path to the
  Authenticated default, which 404s downstream)
- BindRoutes iterates RoutesFor(service) and binds each route's handler by
  ID, panicking when a classified route has no handler so the gap is caught
  at startup and in the per-service registration test
- Generic over the handler type to keep the module web-framework-free;
  services pass gin.HandlerFunc via an engine.Handle adapter
- VerifyRegistration compares a service's registered routes against the
  Registry in both directions and returns a route-naming, Registry-pointing
  error, so each service's coverage test is a thin wrapper over shared logic
- Compares method+path byte-for-byte (params included), pinning Registry
  patterns to gin's real registration and ignoring health/metrics
- Convert auth, finance, expense and datarights RegisterRoutes to bind
  handlers by ID via access.BindRoutes over RoutesFor(service); datarights
  gets a package-level RegisterRoutes merging its export + deletion handlers
- Add a per-service registration coverage test that builds the real engine
  with nil deps and asserts engine.Routes() matches the Registry both ways,
  so an unclassified route fails that service's own suite in CI
- Add the access module COPY lines to all four service Dockerfiles
- Add app/lib/route-access.ts (RouteAccess + canAccess) and a
  useAuthLayoutGuards hook that reads the deepest matched route's
  handle.access via useMatches; each route module now exports its access
  ("personal"/"admin"/"authenticated") and home redirects role-aware
- Delete the FINANCE_ROUTES array and the hasCompletedOnboarding redirect
  hack: a direct admin on a personal route (or a regular user on an admin
  route) now renders a 403 page, and onboarding is role-driven so admins are
  never sent to /onboarding
- Decompose auth-layout into Navbar/DesktopNav/MobileNav/ReturnToAdminButton/
  LogExpenseFab/Forbidden + the guards hook under features/shell-layout;
  auth-layout is a thin orchestrator (<300 LOC), one component per file
- Scope a shell ESLint override so route modules may export handle
- Rewrite auth-layout tests for the 403 model: attach handle.access to test
  route defs and assert direct-admin-on-personal and user-on-admin render the
  Forbidden page, assumed sessions pass, and admins are never onboarding-routed
- Migrate login (incl. the returnTo regression) and register interaction tests
  to userEvent.setup(); fix the stale FINANCE_ROUTE comment
- Keep fireEvent.submit only where a case exercises the submit handler's own
  validation that native required/type=email constraints would otherwise shadow
- data-model: drop the temporal one-time-cleanup clauses and the
  change-management/001 link from the Datarights and Finance notes, keeping
  only the durable invariant (tables hold role=user data only)
- development: state the seed-admin flag is set for data consistency but is
  not the exemption mechanism (admins are structurally exempt via role/route
  metadata); reflect the 403-page behavior instead of a redirect
- testing: describe the per-service registry-driven coverage tests (a new
  unclassified route fails that service's suite in CI) and the 403 guard
- architecture/api: reference services/access instead of the deleted
  policy.go and drop the stale exact/longest-prefix matching description
- Replace the exact/prefix annotations in the assumed-user matrix test with
  plain Personal labels (the resolver is registry-based, not prefix-based)
- Reword the auth ListUsers/AssumeIdentity doc comments to reference the
  shared services/access registry classification instead of an /api/admin
  prefix or a "policy" table
Flip the /api resolver fail-safe from Authenticated to a new Deny level so an
unclassified route (or a whole unclassified prefix) is refused with 403 instead
of silently proxying through as any-authenticated. This extends the "can't ship
an unclassified route" guarantee to new services by construction.

- add Access.Deny; Resolve returns it when no Registry entry matches
- gateway AccessControl short-circuits Deny to 403 before reading the cookie (an
  unclassified path carries no identity); the post-validation switch default
  stays as defense in depth
- real routes keep their exact outcome; only previously-unmatched /api paths
  change (now 403 instead of 401/proxy-through); /health and /metrics stay Public
Add access.ProxyPrefixes (an ordered {Prefix, Service} inventory) as the single
source of truth for which downstream prefixes the gateway proxies and to which
service, and data-drive router.New from it: build one proxy handler per service
and loop the inventory registering each group. Fail fast (panic) if a prefix
names a service with no proxy handler.

A services/access cross-check test pins ProxyPrefixes to the Registry in both
directions: every classified route sits under a proxied prefix (and the right
service), and every proxied prefix has at least one classified route. Combined
with deny-by-default, onboarding a service now fails closed unless its routes
are classified and its prefix is inventoried.

services/access stays gin-free; the gateway imports it, not the services.
Update the access-control docs to reflect the hardening changes:

- the resolver fail-safe is now deny (403) for unclassified /api paths, not the
  Authenticated default (architecture.md, api.md, auth.md, testing.md)
- note that the gateway derives its proxy wiring from access.ProxyPrefixes and
  that a cross-check test pins it to the route registry
- add accessHandle(access) so each route's handle.access is checked against RouteAccess at the call site; a typo now fails to compile instead of silently resolving to a 403
- convert all route modules to accessHandle and remove the vestigial admin-page useEffect/isAdmin guard, since the auth-layout guard renders <Forbidden/> for non-admins before the page mounts
- clarify in useAuthLayoutGuards why access (route metadata) and the current path (useLocation) are read from two distinct sources
- add a canAccess truth table across public/authenticated/personal/admin against direct-admin, regular-user, and assumed-session identities
- add navLinksFor cases pinning the operator vs finance nav surfaces and asserting every link has an icon
@ItsThompson ItsThompson merged commit cc5c280 into main Jul 6, 2026
6 checks passed
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.

2 participants