From cf7fcb062665daa2ee9662d310904781720b3106 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 4 Jul 2026 19:57:04 +0100 Subject: [PATCH 01/70] feat(core): add role helpers and export from barrel 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. --- frontend/packages/core/src/index.ts | 5 +++++ frontend/packages/core/src/roles.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 frontend/packages/core/src/roles.ts diff --git a/frontend/packages/core/src/index.ts b/frontend/packages/core/src/index.ts index c357843e..5fd6c8a2 100644 --- a/frontend/packages/core/src/index.ts +++ b/frontend/packages/core/src/index.ts @@ -13,3 +13,8 @@ export { validateUsername, } from "./validation"; export { formatCurrency, getCurrencySymbol, toCents } from "./currency"; +export { + canUseFinanceFeatures, + canUseAdminFeatures, + getLandingPath, +} from "./roles"; diff --git a/frontend/packages/core/src/roles.ts b/frontend/packages/core/src/roles.ts new file mode 100644 index 00000000..8357d59a --- /dev/null +++ b/frontend/packages/core/src/roles.ts @@ -0,0 +1,28 @@ +import type { User } from "./types"; + +/** + * Single source of frontend role truth. + * + * These helpers are pure, synchronous, and framework-free: no React, no network + * calls. Login, the auth layout, and Settings consume them instead of repeating + * `user.role === "admin"` checks. + * + * Note: an assumed session sets the store's `user` to the target regular user + * (`role=user`), so `canUseFinanceFeatures` is `true` while assuming. Assumption + * is distinguished by the store's `isAssuming` flag, not by role. + */ + +/** A regular user owns and can use personal finance features. */ +export function canUseFinanceFeatures(user: User): boolean { + return user.role === "user"; +} + +/** An operator (admin) can use admin/operational features. */ +export function canUseAdminFeatures(user: User): boolean { + return user.role === "admin"; +} + +/** Where a freshly authenticated user should land. */ +export function getLandingPath(user: User): string { + return canUseAdminFeatures(user) ? "/admin" : "/dashboard"; +} From 74809f0dd054adfa3c6ac81c5b7284e8c08b3c12 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 4 Jul 2026 19:57:06 +0100 Subject: [PATCH 02/70] test(core): cover role helpers truth table 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). --- .../packages/core/__tests__/roles.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 frontend/packages/core/__tests__/roles.test.ts diff --git a/frontend/packages/core/__tests__/roles.test.ts b/frontend/packages/core/__tests__/roles.test.ts new file mode 100644 index 00000000..fb4192f9 --- /dev/null +++ b/frontend/packages/core/__tests__/roles.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import type { User } from "../src/types"; +import { + canUseFinanceFeatures, + canUseAdminFeatures, + getLandingPath, +} from "../src/roles"; + +// Local fixture: core is a dependency-free leaf package, so it cannot import +// the shared `buildUser` factory from @gofin/test-utils (which depends on +// @gofin/core) without creating a circular dependency. Role is the only field +// under test; the rest are valid, representative values. +function makeUser(role: User["role"]): User { + return { + id: "user-1", + username: "testuser", + email: "test@example.com", + role, + currency: "USD", + hasCompletedOnboarding: true, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +const regularUser = makeUser("user"); +const adminUser = makeUser("admin"); + +describe("canUseFinanceFeatures", () => { + it("is true for a regular user (role=user)", () => { + expect(canUseFinanceFeatures(regularUser)).toBe(true); + }); + + it("is false for an admin user (role=admin)", () => { + expect(canUseFinanceFeatures(adminUser)).toBe(false); + }); +}); + +describe("canUseAdminFeatures", () => { + it("is true for an admin user (role=admin)", () => { + expect(canUseAdminFeatures(adminUser)).toBe(true); + }); + + it("is false for a regular user (role=user)", () => { + expect(canUseAdminFeatures(regularUser)).toBe(false); + }); +}); + +describe("getLandingPath", () => { + it("returns /admin for an admin user", () => { + expect(getLandingPath(adminUser)).toBe("/admin"); + }); + + it("returns /dashboard for a regular user", () => { + expect(getLandingPath(regularUser)).toBe("/dashboard"); + }); +}); From 61ff0279c60f0e0888e756acecdf3bb871c53b60 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 4 Jul 2026 19:57:26 +0100 Subject: [PATCH 03/70] feat(cm): scaffold change-management layout - 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 --- change-management/.tools/.gitkeep | 0 change-management/.validation/.gitkeep | 0 change-management/000-template/assets/.gitkeep | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 change-management/.tools/.gitkeep create mode 100644 change-management/.validation/.gitkeep create mode 100644 change-management/000-template/assets/.gitkeep diff --git a/change-management/.tools/.gitkeep b/change-management/.tools/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/change-management/.validation/.gitkeep b/change-management/.validation/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/change-management/000-template/assets/.gitkeep b/change-management/000-template/assets/.gitkeep new file mode 100644 index 00000000..e69de29b From d774f62e307bbdf0ee136bd99d63a34649d84d52 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 4 Jul 2026 19:57:30 +0100 Subject: [PATCH 04/70] feat(cm): add 000-template description.md - 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 --- change-management/000-template/description.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 change-management/000-template/description.md diff --git a/change-management/000-template/description.md b/change-management/000-template/description.md new file mode 100644 index 00000000..12d1a665 --- /dev/null +++ b/change-management/000-template/description.md @@ -0,0 +1,73 @@ +# Description — _ + +> Copy this template with `cp -r 000-template _` and answer every +> prompt below. Do not add, remove, or rename the `##`/`####` headings: the validator +> enforces this exact structure. Replace each `<...>` placeholder with your content. + +## Change Event + +#### What is the purpose of this activity or change? + + + +#### What will be required to execute this change? + + + +#### What is the expected end state of the system after this change? + + + +#### What assumptions, if any, are being made about the state of the system at the time of this change? + + + +#### Rollout Date/Time(s) and Duration + + + +## Impact / Risk Assessment + +#### Why is it necessary? What is the impact of not making this change? + + + +#### Why does this activity or change need to be done under Change Management? Can it be safely automated? + + + +#### Are there any related, prerequisite changes upon which this CM hinges? + + + +#### Will this CM be in any way intrusive, and if so, how will you know? What teams, services or functionality will be impacted? + + + +#### How has this change been tested to verify it's safe for production? + + + +## Worst Case Scenario + +#### What could happen if everything goes wrong with this change? + + + +#### How does this CM attempt to mitigate this risk? + + + +## Rollback Procedure + +#### What conditions would indicate a need to rollback? + + + +#### In the event of problems, what will you do to return your system to a known good state? + + + +#### If this is a software or infrastructure change, has the rollback procedure been verified in a development environment? + + From 557ada4ff99d1801e8a4b279e43dd278bc918e37 Mon Sep 17 00:00:00 2001 From: thompson Date: Sat, 4 Jul 2026 19:57:34 +0100 Subject: [PATCH 05/70] feat(cm): add 000-template preflight and steps - 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 --- change-management/000-template/preflight.md | 48 ++++++++++++++++++++ change-management/000-template/steps.md | 49 +++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 change-management/000-template/preflight.md create mode 100644 change-management/000-template/steps.md diff --git a/change-management/000-template/preflight.md b/change-management/000-template/preflight.md new file mode 100644 index 00000000..f7837619 --- /dev/null +++ b/change-management/000-template/preflight.md @@ -0,0 +1,48 @@ +# Preflight — _ + +> Everything done *before* the change is executed (merge, deploy, dry-run, backups). +> Every `# Activity N` MUST be followed by a matching `# Validation N` with the same +> index. Each block requires a `**Description**` line, a `## Checklist:` section, and a +> `## Rollback Plan:` section. The validator enforces the pairing and these sections. +> Add or remove pairs as needed; keep the indexes contiguous starting at 1. + +# Activity 1: + +**Description**: <what this action does and why it happens before execution> + +## Checklist: +1. <step> +2. <step> + +## Rollback Plan: +1. <how to undo this activity if it must be reversed> + +# Validation 1: <title> + +**Description**: <how to confirm Activity 1 succeeded> + +## Checklist: +1. <observable check that proves success> + +## Rollback Plan: +1. <what to do if this validation fails: stop and undo Activity 1> + +# Activity 2: <title> + +**Description**: <what this action does> + +## Checklist: +1. <step> + +## Rollback Plan: +1. <how to undo this activity> + +# Validation 2: <title> + +**Description**: <how to confirm Activity 2 succeeded> + +## Checklist: +1. <observable check that proves success> + +## Rollback Plan: +1. <what to do if this validation fails> diff --git a/change-management/000-template/steps.md b/change-management/000-template/steps.md new file mode 100644 index 00000000..a9eb0834 --- /dev/null +++ b/change-management/000-template/steps.md @@ -0,0 +1,49 @@ +# Steps — <NNN>_<kebab-title> + +> The execution itself, plus any repo housekeeping that finalizes the change (e.g. +> committing the execution log and renaming the folder with a status suffix). +> Every `# Activity N` MUST be followed by a matching `# Validation N` with the same +> index. Each block requires a `**Description**` line, a `## Checklist:` section, and a +> `## Rollback Plan:` section. The validator enforces the pairing and these sections. +> Add or remove pairs as needed; keep the indexes contiguous starting at 1. + +# Activity 1: <title> + +**Description**: <the change action being performed> + +## Checklist: +1. <step> +2. <step> + +## Rollback Plan: +1. <how to return the system to a known good state if this action fails> + +# Validation 1: <title> + +**Description**: <how to confirm Activity 1 succeeded> + +## Checklist: +1. <observable check that proves success> + +## Rollback Plan: +1. <what to do if this validation fails> + +# Activity 2: <title> + +**Description**: <finalization action, e.g. commit the execution log and rename the folder with a status suffix> + +## Checklist: +1. <step> + +## Rollback Plan: +1. <how to undo this finalization action> + +# Validation 2: <title> + +**Description**: <how to confirm Activity 2 succeeded, e.g. CI including validate-change-management is green> + +## Checklist: +1. <observable check that proves success> + +## Rollback Plan: +1. <what to do if this validation fails> From 0617d64630b0304e974e13d94768de1df9b9cb49 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 19:57:40 +0100 Subject: [PATCH 06/70] feat(change-mgmt): add admin finance cleanup SQL assets 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. --- .../001_admin-finance-cleanup/assets/cleanup.sql | 12 ++++++++++++ .../001_admin-finance-cleanup/assets/dry-run.sql | 7 +++++++ 2 files changed, 19 insertions(+) create mode 100644 change-management/001_admin-finance-cleanup/assets/cleanup.sql create mode 100644 change-management/001_admin-finance-cleanup/assets/dry-run.sql diff --git a/change-management/001_admin-finance-cleanup/assets/cleanup.sql b/change-management/001_admin-finance-cleanup/assets/cleanup.sql new file mode 100644 index 00000000..164bdded --- /dev/null +++ b/change-management/001_admin-finance-cleanup/assets/cleanup.sql @@ -0,0 +1,12 @@ +-- Transactional, idempotent, admin-scoped cleanup of admin-owned finance data. +-- Safe to re-run: a second run deletes zero rows. +-- Uniform style: every statement uses the same inline admin subquery. +BEGIN; + +DELETE FROM finance.pro_rata_schedules WHERE user_id IN (SELECT id FROM auth.users WHERE role = 'admin'); +DELETE FROM finance.tags WHERE user_id IN (SELECT id FROM auth.users WHERE role = 'admin'); +DELETE FROM finance.budget_periods WHERE user_id IN (SELECT id FROM auth.users WHERE role = 'admin'); +DELETE FROM finance.default_settings WHERE user_id IN (SELECT id FROM auth.users WHERE role = 'admin'); +DELETE FROM datarights.export_jobs WHERE user_id IN (SELECT id FROM auth.users WHERE role = 'admin'); + +COMMIT; diff --git a/change-management/001_admin-finance-cleanup/assets/dry-run.sql b/change-management/001_admin-finance-cleanup/assets/dry-run.sql new file mode 100644 index 00000000..f64fff30 --- /dev/null +++ b/change-management/001_admin-finance-cleanup/assets/dry-run.sql @@ -0,0 +1,7 @@ +-- Read-only. Reports admin-owned rows in each cleanup target. +WITH admins AS (SELECT id FROM auth.users WHERE role = 'admin') +SELECT 'finance.pro_rata_schedules' AS table_name, count(*) FROM finance.pro_rata_schedules WHERE user_id IN (SELECT id FROM admins) +UNION ALL SELECT 'finance.tags', count(*) FROM finance.tags WHERE user_id IN (SELECT id FROM admins) +UNION ALL SELECT 'finance.budget_periods', count(*) FROM finance.budget_periods WHERE user_id IN (SELECT id FROM admins) +UNION ALL SELECT 'finance.default_settings',count(*) FROM finance.default_settings WHERE user_id IN (SELECT id FROM admins) +UNION ALL SELECT 'datarights.export_jobs', count(*) FROM datarights.export_jobs WHERE user_id IN (SELECT id FROM admins); From b191c1bf48d278650dd936079ef83b6c8f773af7 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 19:59:00 +0100 Subject: [PATCH 07/70] feat(gateway): add access model types --- services/gateway/internal/access/access.go | 68 ++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 services/gateway/internal/access/access.go diff --git a/services/gateway/internal/access/access.go b/services/gateway/internal/access/access.go new file mode 100644 index 00000000..dfb07c2b --- /dev/null +++ b/services/gateway/internal/access/access.go @@ -0,0 +1,68 @@ +// Package access owns the GoFin gateway access model as pure, gin-free code. +// +// It defines the access levels applied to every gateway route, the rule and +// policy types that encode who may reach what, and a pure resolver that maps a +// method+path to an access level. Keeping this package free of gin and +// net/http lets the entire access model be exhaustively table-tested without a +// running server. +package access + +// Access is the classification applied to every gateway route. It determines +// whether a request needs a token and, if so, which role may proceed. +type Access int + +const ( + // Public routes are reachable with no token. + Public Access = iota + // Authenticated routes require any valid token, regardless of role. + Authenticated + // Personal routes require a valid token acting as a regular user (role == "user"). + Personal + // Admin routes require a valid token acting as an operator (role == "admin"). + Admin +) + +// String returns the level name, used for readable logs and test diagnostics. +func (a Access) String() string { + switch a { + case Public: + return "Public" + case Authenticated: + return "Authenticated" + case Personal: + return "Personal" + case Admin: + return "Admin" + default: + return "Unknown" + } +} + +// MatchType selects exact vs prefix matching for a Rule. +type MatchType int + +const ( + // Exact matches when the method and path are identical to the rule. + Exact MatchType = iota + // Prefix matches when the request path starts with the rule path. + Prefix +) + +// Rule maps a method+path pattern to an access level. +type Rule struct { + // Method is the HTTP method to match. "" means any method (only meaningful + // for Prefix rules and method-agnostic exacts). + Method string + // Path is the exact path (Exact) or the path prefix (Prefix) to match. + Path string + // Match selects exact vs prefix matching. + Match MatchType + // Access is the level granted when this rule matches. + Access Access +} + +// Policy is the ordered list of rules plus the fallback for unmatched paths. +type Policy struct { + rules []Rule + Default Access // Authenticated (fail-safe) +} From cb5ec1e3fd86c807b5538dd32b9c91f016a9234c Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 19:59:04 +0100 Subject: [PATCH 08/70] feat(gateway): add canonical default policy table --- services/gateway/internal/access/policy.go | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 services/gateway/internal/access/policy.go diff --git a/services/gateway/internal/access/policy.go b/services/gateway/internal/access/policy.go new file mode 100644 index 00000000..c19c61f4 --- /dev/null +++ b/services/gateway/internal/access/policy.go @@ -0,0 +1,45 @@ +package access + +// HTTP method constants used by the policy table. These are plain strings +// matching the values gin reports for c.Request.Method (identical to the +// net/http.Method* constants), kept local so the access package stays free of +// any net/http dependency and remains purely table-testable. +const ( + methodGet = "GET" + methodPost = "POST" + methodDelete = "DELETE" +) + +// DefaultPolicy returns the canonical GoFin access policy table. It is the +// single source of truth for who can reach what. Rule order is for +// readability only: the resolver is order-independent within a match class +// (see resolver.go), grouping rules here by access level to mirror the spec. +func DefaultPolicy() Policy { + return Policy{ + Default: Authenticated, // fail-safe: unmatched routes require any valid token + rules: []Rule{ + // Public: reachable with no token. + {Method: methodPost, Path: "/api/auth/register", Match: Exact, Access: Public}, + {Method: methodPost, Path: "/api/auth/login", Match: Exact, Access: Public}, + {Method: methodPost, Path: "/api/auth/refresh", Match: Exact, Access: Public}, + {Method: methodGet, Path: "/health", Match: Exact, Access: Public}, + {Method: methodGet, Path: "/metrics", Match: Exact, Access: Public}, + + // Admin: operator-only. + {Method: "", Path: "/api/admin", Match: Prefix, Access: Admin}, + {Method: methodPost, Path: "/api/auth/assume", Match: Exact, Access: Admin}, + {Method: "", Path: "/api/datarights/deletions", Match: Prefix, Access: Admin}, + + // Personal: valid token acting as a regular user (role == "user"). + {Method: methodPost, Path: "/api/auth/onboarding-complete", Match: Exact, Access: Personal}, + {Method: "", Path: "/api/finance", Match: Prefix, Access: Personal}, + {Method: "", Path: "/api/expenses", Match: Prefix, Access: Personal}, + {Method: "", Path: "/api/datarights/exports", Match: Prefix, Access: Personal}, + + // Authenticated: any valid token, no role check. + {Method: "", Path: "/api/auth/me", Match: Prefix, Access: Authenticated}, + {Method: methodPost, Path: "/api/auth/logout", Match: Exact, Access: Authenticated}, + {Method: methodPost, Path: "/api/auth/restore", Match: Exact, Access: Authenticated}, + }, + } +} From f4f28c9ec692661befa0529c907f95a2e351809f Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 19:59:07 +0100 Subject: [PATCH 09/70] feat(gateway): add pure access resolver --- services/gateway/internal/access/resolver.go | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 services/gateway/internal/access/resolver.go diff --git a/services/gateway/internal/access/resolver.go b/services/gateway/internal/access/resolver.go new file mode 100644 index 00000000..e9b5fbf6 --- /dev/null +++ b/services/gateway/internal/access/resolver.go @@ -0,0 +1,41 @@ +package access + +import "strings" + +// resolve returns the access level for a method+path. +// +// Precedence: +// 1. Exact match: a Match==Exact rule whose Method matches (or is "") and +// whose Path equals path. +// 2. Longest prefix: among Match==Prefix rules whose Method matches (or is "") +// and where path starts with the rule Path, the one with the longest Path. +// 3. Default: Policy.Default (Authenticated, the fail-safe). +// +// resolve is a pure function with no gin or net/http dependency, so the whole +// access model can be exhaustively table-tested without a server. +func (p Policy) resolve(method, path string) Access { + for _, rule := range p.rules { + if rule.Match == Exact && methodMatches(rule.Method, method) && rule.Path == path { + return rule.Access + } + } + + bestLen := -1 + access := p.Default + for _, rule := range p.rules { + if rule.Match != Prefix || !methodMatches(rule.Method, method) { + continue + } + if strings.HasPrefix(path, rule.Path) && len(rule.Path) > bestLen { + bestLen = len(rule.Path) + access = rule.Access + } + } + return access +} + +// methodMatches reports whether a rule's method constraint is satisfied by the +// request method. An empty rule method means "any method". +func methodMatches(ruleMethod, requestMethod string) bool { + return ruleMethod == "" || ruleMethod == requestMethod +} From 952ab4a824b54e2358c0dcf24adc223cf3ad2a64 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 19:59:09 +0100 Subject: [PATCH 10/70] test(gateway): add table-driven resolver tests --- .../gateway/internal/access/resolver_test.go | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 services/gateway/internal/access/resolver_test.go diff --git a/services/gateway/internal/access/resolver_test.go b/services/gateway/internal/access/resolver_test.go new file mode 100644 index 00000000..f4a852f2 --- /dev/null +++ b/services/gateway/internal/access/resolver_test.go @@ -0,0 +1,137 @@ +package access + +import "testing" + +// TestResolve_DefaultPolicy covers the section-4 worked examples plus +// exhaustive cases across every rule in the canonical policy table: Public +// exacts, method-agnostic prefixes, exact-over-default precedence, longest +// prefix ranking, and the Authenticated fail-safe default. +func TestResolve_DefaultPolicy(t *testing.T) { + policy := DefaultPolicy() + + cases := []struct { + name string + method string + path string + want Access + }{ + // --- Section-4 worked examples (acceptance criteria) --- + {"login is public", "POST", "/api/auth/login", Public}, + {"me is authenticated (prefix)", "GET", "/api/auth/me", Authenticated}, + {"me password is authenticated (prefix)", "POST", "/api/auth/me/password", Authenticated}, + {"onboarding-complete is personal (exact)", "POST", "/api/auth/onboarding-complete", Personal}, + {"assume is admin (exact)", "POST", "/api/auth/assume", Admin}, + {"restore is authenticated (exact)", "POST", "/api/auth/restore", Authenticated}, + {"finance periods is personal (prefix)", "GET", "/api/finance/periods", Personal}, + {"exports is personal (prefix)", "POST", "/api/datarights/exports", Personal}, + {"deletions by id is admin (longest prefix)", "DELETE", "/api/datarights/deletions/abc-123", Admin}, + {"admin users is admin (prefix)", "GET", "/api/admin/users", Admin}, + {"bare auth group falls to default", "GET", "/api/auth", Authenticated}, + + // --- Remaining Public exacts --- + {"register is public", "POST", "/api/auth/register", Public}, + {"refresh is public", "POST", "/api/auth/refresh", Public}, + {"health is public", "GET", "/health", Public}, + {"metrics is public", "GET", "/metrics", Public}, + + // --- Remaining Authenticated exacts --- + {"logout is authenticated (exact)", "POST", "/api/auth/logout", Authenticated}, + + // --- Personal whole-service prefixes --- + {"finance bare group is personal", "GET", "/api/finance", Personal}, + {"expenses is personal (prefix)", "POST", "/api/expenses", Personal}, + {"expenses subpath is personal", "GET", "/api/expenses/123", Personal}, + {"exports subpath is personal (longest prefix)", "GET", "/api/datarights/exports/export-456", Personal}, + + // --- Admin prefixes --- + {"admin bare group is admin", "GET", "/api/admin", Admin}, + {"deletions bare is admin (prefix)", "POST", "/api/datarights/deletions", Admin}, + + // --- Method sensitivity: an exact rule's method must match --- + {"wrong method on public exact falls to default", "GET", "/api/auth/login", Authenticated}, + {"wrong method on admin exact falls to default", "GET", "/api/auth/assume", Authenticated}, + + // --- Default fallback for unclassified paths --- + {"bare datarights falls to default", "GET", "/api/datarights", Authenticated}, + {"unknown datarights subpath falls to default", "GET", "/api/datarights/unknown", Authenticated}, + {"unknown api path falls to default", "GET", "/api/unknown", Authenticated}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := policy.resolve(tc.method, tc.path) + if got != tc.want { + t.Errorf("resolve(%q, %q) = %s, want %s", tc.method, tc.path, got, tc.want) + } + }) + } +} + +// TestResolve_ExactBeatsPrefix proves the exact pass runs before the prefix +// pass: even when a broader prefix rule would match the same path, the exact +// rule wins regardless of table order. +func TestResolve_ExactBeatsPrefix(t *testing.T) { + policy := Policy{ + Default: Authenticated, + rules: []Rule{ + {Method: "", Path: "/api/auth", Match: Prefix, Access: Personal}, + {Method: "POST", Path: "/api/auth/assume", Match: Exact, Access: Admin}, + }, + } + + if got := policy.resolve("POST", "/api/auth/assume"); got != Admin { + t.Errorf("exact rule should win over prefix: got %s, want %s", got, Admin) + } + // A sibling path with no exact rule falls to the prefix rule. + if got := policy.resolve("GET", "/api/auth/other"); got != Personal { + t.Errorf("prefix rule should apply when no exact matches: got %s, want %s", got, Personal) + } +} + +// TestResolve_LongestPrefixWins proves the resolver picks the longest matching +// prefix, independent of the order rules appear in the table. +func TestResolve_LongestPrefixWins(t *testing.T) { + // Shorter prefix listed AFTER the longer one to ensure ordering is not + // what selects the winner. + policy := Policy{ + Default: Authenticated, + rules: []Rule{ + {Method: "", Path: "/api/datarights/deletions", Match: Prefix, Access: Admin}, + {Method: "", Path: "/api/datarights", Match: Prefix, Access: Personal}, + }, + } + + if got := policy.resolve("DELETE", "/api/datarights/deletions/1"); got != Admin { + t.Errorf("longest prefix should win: got %s, want %s", got, Admin) + } + if got := policy.resolve("GET", "/api/datarights/other"); got != Personal { + t.Errorf("shorter prefix should apply when longer does not match: got %s, want %s", got, Personal) + } +} + +// TestResolve_DefaultFallback confirms an empty policy (and any unmatched path) +// returns the configured Default. +func TestResolve_DefaultFallback(t *testing.T) { + policy := Policy{Default: Authenticated} + if got := policy.resolve("GET", "/anything"); got != Authenticated { + t.Errorf("empty policy should return Default: got %s, want %s", got, Authenticated) + } + + custom := Policy{Default: Public} + if got := custom.resolve("GET", "/anything"); got != Public { + t.Errorf("Default should be honored: got %s, want %s", got, Public) + } +} + +// TestResolve_MethodAgnosticPrefix confirms a prefix rule with an empty method +// matches every HTTP method, while a method-scoped exact only matches its +// method. +func TestResolve_MethodAgnosticPrefix(t *testing.T) { + policy := DefaultPolicy() + + for _, method := range []string{"GET", "POST", "PUT", "DELETE", "PATCH"} { + if got := policy.resolve(method, "/api/admin/users"); got != Admin { + t.Errorf("method-agnostic prefix should match %s: got %s, want %s", method, got, Admin) + } + } +} From 7714ba458bc7805b9797103de8af0829042f83c5 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 20:02:16 +0100 Subject: [PATCH 11/70] style(cm): drop em-dashes from template titles - 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 --- change-management/000-template/description.md | 2 +- change-management/000-template/preflight.md | 2 +- change-management/000-template/steps.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/change-management/000-template/description.md b/change-management/000-template/description.md index 12d1a665..c9a9bf93 100644 --- a/change-management/000-template/description.md +++ b/change-management/000-template/description.md @@ -1,4 +1,4 @@ -# Description — <NNN>_<kebab-title> +# Description: <NNN>_<kebab-title> > Copy this template with `cp -r 000-template <NNN>_<kebab-title>` and answer every > prompt below. Do not add, remove, or rename the `##`/`####` headings: the validator diff --git a/change-management/000-template/preflight.md b/change-management/000-template/preflight.md index f7837619..36a946e4 100644 --- a/change-management/000-template/preflight.md +++ b/change-management/000-template/preflight.md @@ -1,4 +1,4 @@ -# Preflight — <NNN>_<kebab-title> +# Preflight: <NNN>_<kebab-title> > Everything done *before* the change is executed (merge, deploy, dry-run, backups). > Every `# Activity N` MUST be followed by a matching `# Validation N` with the same diff --git a/change-management/000-template/steps.md b/change-management/000-template/steps.md index a9eb0834..acb7e1b4 100644 --- a/change-management/000-template/steps.md +++ b/change-management/000-template/steps.md @@ -1,4 +1,4 @@ -# Steps — <NNN>_<kebab-title> +# Steps: <NNN>_<kebab-title> > The execution itself, plus any repo housekeeping that finalizes the change (e.g. > committing the execution log and renaming the folder with a status suffix). From e8345164e47bdefeb50d45377bb5be66e174e0e6 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 20:03:42 +0100 Subject: [PATCH 12/70] refactor(gateway): rename resolver local var to avoid shadowing package name --- services/gateway/internal/access/resolver.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/gateway/internal/access/resolver.go b/services/gateway/internal/access/resolver.go index e9b5fbf6..37e4163c 100644 --- a/services/gateway/internal/access/resolver.go +++ b/services/gateway/internal/access/resolver.go @@ -21,17 +21,17 @@ func (p Policy) resolve(method, path string) Access { } bestLen := -1 - access := p.Default + level := p.Default for _, rule := range p.rules { if rule.Match != Prefix || !methodMatches(rule.Method, method) { continue } if strings.HasPrefix(path, rule.Path) && len(rule.Path) > bestLen { bestLen = len(rule.Path) - access = rule.Access + level = rule.Access } } - return access + return level } // methodMatches reports whether a rule's method constraint is satisfied by the From 6abf0090fb84493470dacc1e210374e5809fe723 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:04:00 +0100 Subject: [PATCH 13/70] fix(dbmigrate): strip golang-migrate x- params from raw schema connection 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. --- services/dbmigrate/dbmigrate.go | 38 +++++++++---- services/dbmigrate/dbmigrate_internal_test.go | 53 +++++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 services/dbmigrate/dbmigrate_internal_test.go diff --git a/services/dbmigrate/dbmigrate.go b/services/dbmigrate/dbmigrate.go index be988203..8f90fcff 100644 --- a/services/dbmigrate/dbmigrate.go +++ b/services/dbmigrate/dbmigrate.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "net/url" + "strings" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" @@ -68,23 +69,16 @@ func Run(dbURL, migrationsPath string) error { // because the postgres driver fails with "no schema" if search_path references // a non-existent schema. func ensureSchema(dbURL string) error { - parsed, err := url.Parse(dbURL) + searchPath, connURL, err := rawConnectionURL(dbURL) if err != nil { return fmt.Errorf("parsing database URL: %w", err) } - searchPath := parsed.Query().Get("search_path") if searchPath == "" { return nil } - // Connect without search_path so the connection succeeds even if the schema - // doesn't exist yet. - q := parsed.Query() - q.Del("search_path") - parsed.RawQuery = q.Encode() - - db, err := sql.Open("postgres", parsed.String()) + db, err := sql.Open("postgres", connURL) if err != nil { return fmt.Errorf("connecting to database: %w", err) } @@ -98,3 +92,29 @@ func ensureSchema(dbURL string) error { slog.Info("dbmigrate: schema ensured", slog.String("schema", searchPath)) return nil } + +// rawConnectionURL returns the search_path from dbURL along with a connection +// URL suitable for a plain lib/pq connection. It strips both the search_path +// (so the connection succeeds even if the schema doesn't exist yet) and any +// golang-migrate driver params (the x-* family, e.g. x-migrations-table). Those +// x-* params are consumed by golang-migrate's own connection, not by a raw +// lib/pq connection, which would otherwise reject them as unknown server +// configuration parameters. +func rawConnectionURL(dbURL string) (searchPath, connURL string, err error) { + parsed, err := url.Parse(dbURL) + if err != nil { + return "", "", err + } + + q := parsed.Query() + searchPath = q.Get("search_path") + q.Del("search_path") + for key := range q { + if strings.HasPrefix(key, "x-") { + q.Del(key) + } + } + parsed.RawQuery = q.Encode() + + return searchPath, parsed.String(), nil +} diff --git a/services/dbmigrate/dbmigrate_internal_test.go b/services/dbmigrate/dbmigrate_internal_test.go new file mode 100644 index 00000000..5ed22204 --- /dev/null +++ b/services/dbmigrate/dbmigrate_internal_test.go @@ -0,0 +1,53 @@ +package dbmigrate + +import "testing" + +func TestRawConnectionURL(t *testing.T) { + tests := []struct { + name string + dbURL string + wantSearchPath string + wantConnURL string + }{ + { + name: "no search_path leaves url unchanged", + dbURL: "postgres://u:p@localhost:5432/db?sslmode=disable", + wantSearchPath: "", + wantConnURL: "postgres://u:p@localhost:5432/db?sslmode=disable", + }, + { + name: "search_path is extracted and removed", + dbURL: "postgres://u:p@localhost:5432/db?search_path=finance&sslmode=disable", + wantSearchPath: "finance", + wantConnURL: "postgres://u:p@localhost:5432/db?sslmode=disable", + }, + { + name: "golang-migrate x- params are stripped for the raw connection", + dbURL: "postgres://u:p@localhost:5432/db?search_path=auth&x-migrations-table=schema_migrations_auth&sslmode=disable", + wantSearchPath: "auth", + wantConnURL: "postgres://u:p@localhost:5432/db?sslmode=disable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + searchPath, connURL, err := rawConnectionURL(tt.dbURL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if searchPath != tt.wantSearchPath { + t.Errorf("searchPath: got %q, want %q", searchPath, tt.wantSearchPath) + } + if connURL != tt.wantConnURL { + t.Errorf("connURL: got %q, want %q", connURL, tt.wantConnURL) + } + }) + } +} + +func TestRawConnectionURL_InvalidURL(t *testing.T) { + _, _, err := rawConnectionURL("://not-a-url") + if err == nil { + t.Fatal("expected error for invalid URL, got nil") + } +} From 426d8e98771c649742966e5cea7172d118223479 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:04:07 +0100 Subject: [PATCH 14/70] test(dbmigrate): add admin finance cleanup safety test 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. --- .../dbmigrate/admin_finance_cleanup_test.go | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 services/dbmigrate/admin_finance_cleanup_test.go diff --git a/services/dbmigrate/admin_finance_cleanup_test.go b/services/dbmigrate/admin_finance_cleanup_test.go new file mode 100644 index 00000000..f4d369b7 --- /dev/null +++ b/services/dbmigrate/admin_finance_cleanup_test.go @@ -0,0 +1,307 @@ +package dbmigrate_test + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "reflect" + "testing" + "time" + + _ "github.com/lib/pq" + + "github.com/ItsThompson/gofin/services/dbmigrate" +) + +// TestAdminFinanceCleanup proves that the shipping cleanup.sql is correctly +// scoped and idempotent before it is ever run on prod. It is TEMPORARY: the +// completion PR for change-management item 001 removes this file once the +// operation has run (the SQL and markdown remain as the permanent record). +// +// The test is gated on TEST_DATABASE_URL and skips when unset, matching the +// other gated dbmigrate tests. Run it locally against a disposable Postgres: +// +// TEST_DATABASE_URL='postgres://gofin:gofin@localhost:5432/gofin?sslmode=disable' \ +// go test ./dbmigrate/ -run TestAdminFinanceCleanup + +// cleanupSQLPath points at the exact file that ships in the 001 item, relative +// to services/dbmigrate/, so the test exercises the shipping asset itself. +const cleanupSQLPath = "../../change-management/001_admin-finance-cleanup/assets/cleanup.sql" + +// targetTables are the five tables cleanup.sql deletes admin-owned rows from. +// Every one has a user_id column, so counts can be scoped by owner. +var targetTables = []string{ + "finance.pro_rata_schedules", + "finance.tags", + "finance.budget_periods", + "finance.default_settings", + "datarights.export_jobs", +} + +// serviceMigration describes one service's migration run against the shared test +// database: its canonical migrations dir (relative to services/dbmigrate/), the +// schema it owns, and a distinct golang-migrate bookkeeping table so the three +// services' schema_migrations do not collide, mirroring prod's per-service +// search_path model. +type serviceMigration struct { + dir string + searchPath string + migrationsTable string +} + +var serviceMigrations = []serviceMigration{ + {dir: "../auth/db/migrations", searchPath: "auth", migrationsTable: "schema_migrations_auth"}, + {dir: "../finance/db/migrations", searchPath: "finance", migrationsTable: "schema_migrations_finance"}, + {dir: "../datarights/db/migrations", searchPath: "datarights", migrationsTable: "schema_migrations_datarights"}, +} + +// wantUserRows is the seeded finance-row count per table for one regular user; +// none of these may be touched by cleanup.sql. +var wantUserRows = map[string]int{ + "finance.pro_rata_schedules": 1, + "finance.tags": 2, + "finance.budget_periods": 1, + "finance.default_settings": 1, + "datarights.export_jobs": 1, +} + +func TestAdminFinanceCleanup(t *testing.T) { + baseURL := os.Getenv("TEST_DATABASE_URL") + if baseURL == "" { + t.Skip("TEST_DATABASE_URL not set: skipping integration test") + } + + runServiceMigrations(t, baseURL) + db := openDB(t, baseURL) + + adminID := seedUser(t, db, "cleanup_admin", "admin") + userID := seedUser(t, db, "cleanup_user", "user") + seedFinanceRows(t, db, adminID) + seedFinanceRows(t, db, userID) + auditID := seedDeletionJob(t, db, userID, adminID) + + // Pre-cleanup sanity: the admin owns rows in every target table. + for _, table := range targetTables { + if countByUser(t, db, table, adminID) == 0 { + t.Fatalf("seed check failed: admin owns no rows in %s", table) + } + } + + runCleanupSQL(t, db) + + // Scoping: every admin-owned row is gone from the five target tables. + for _, table := range targetTables { + if got := countByUser(t, db, table, adminID); got != 0 { + t.Errorf("admin rows not deleted from %s: got %d, want 0", table, got) + } + } + + // Blast radius: regular-user finance rows are untouched. + for table, want := range wantUserRows { + if got := countByUser(t, db, table, userID); got != want { + t.Errorf("regular-user rows in %s changed: got %d, want %d", table, got, want) + } + } + + // The admin identity itself must survive (operator accounts must remain). + if got := queryCount(t, db, + "SELECT count(*) FROM auth.users WHERE id = $1 AND role = 'admin'", adminID); got != 1 { + t.Errorf("auth.users admin row was modified: got %d, want 1", got) + } + + // Audit data in datarights.deletion_jobs must survive. + if got := queryCount(t, db, + "SELECT count(*) FROM datarights.deletion_jobs WHERE id = $1", auditID); got != 1 { + t.Errorf("datarights.deletion_jobs audit row was deleted: got %d, want 1", got) + } + + // Idempotency: a second run deletes zero rows and leaves the DB unchanged. + before := snapshot(t, db, adminID, userID, auditID) + runCleanupSQL(t, db) + after := snapshot(t, db, adminID, userID, auditID) + if !reflect.DeepEqual(before, after) { + t.Errorf("cleanup.sql is not idempotent: state changed on second run\nbefore: %v\nafter: %v", before, after) + } +} + +// runServiceMigrations applies the auth, finance, and datarights migrations to +// the test database, each on its own search_path and bookkeeping table. +func runServiceMigrations(t *testing.T, baseURL string) { + t.Helper() + for _, svc := range serviceMigrations { + dbURL := withParams(t, baseURL, map[string]string{ + "search_path": svc.searchPath, + "x-migrations-table": svc.migrationsTable, + }) + if err := dbmigrate.Run(dbURL, svc.dir); err != nil { + t.Fatalf("running %s migrations: %v", svc.searchPath, err) + } + } +} + +// openDB connects for seeding, running cleanup.sql, and asserting. Every query +// uses fully-qualified table names, so the connection carries no search_path. +func openDB(t *testing.T, baseURL string) *sql.DB { + t.Helper() + db, err := sql.Open("postgres", stripParams(t, baseURL, "search_path", "x-migrations-table")) + if err != nil { + t.Fatalf("opening test database: %v", err) + } + if err := db.Ping(); err != nil { + t.Fatalf("pinging test database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// seedUser inserts an auth.users row with the given role and returns its id. +// The row and everything it owns is removed on cleanup so the test re-runs. +func seedUser(t *testing.T, db *sql.DB, prefix, role string) string { + t.Helper() + suffix := time.Now().UnixNano() + var id string + err := db.QueryRow( + `INSERT INTO auth.users (username, email, password_hash, role) + VALUES ($1, $2, 'x', $3) RETURNING id::text`, + fmt.Sprintf("%s_%d", prefix, suffix), + fmt.Sprintf("%s_%d@cleanup.test", prefix, suffix), + role, + ).Scan(&id) + if err != nil { + t.Fatalf("seeding %s user: %v", role, err) + } + t.Cleanup(func() { deleteUserData(db, id) }) + return id +} + +// seedFinanceRows inserts one owner's set of finance rows plus an export job: +// a budget period, default settings, two tags, one pro-rata schedule, one +// export job. finance and datarights carry no cross-schema FK to auth.users +// (project convention), so synthetic tag/group ids are fine. +func seedFinanceRows(t *testing.T, db *sql.DB, userID string) { + t.Helper() + mustExec(t, db, `INSERT INTO finance.budget_periods + (user_id, year, month, budget_amount, essentials_percent, desires_percent, savings_percent) + VALUES ($1, 2026, 1, 100000, 50, 30, 20)`, userID) + mustExec(t, db, `INSERT INTO finance.default_settings (user_id) VALUES ($1)`, userID) + mustExec(t, db, `INSERT INTO finance.tags (user_id, name) VALUES ($1, 'Groceries'), ($1, 'Rent')`, userID) + mustExec(t, db, `INSERT INTO finance.pro_rata_schedules + (user_id, pro_rata_group, name, amount, currency, expense_type, tag_id, + target_year, target_month, installment_index, installment_total) + VALUES ($1, gen_random_uuid(), 'Annual insurance', 120000, 'USD', 'essentials', + gen_random_uuid(), 2026, 1, 1, 12)`, userID) + mustExec(t, db, `INSERT INTO datarights.export_jobs (user_id) VALUES ($1)`, userID) +} + +// seedDeletionJob inserts an audit row that records the admin acting on a user. +// cleanup.sql must never touch datarights.deletion_jobs. Returns its id. +func seedDeletionJob(t *testing.T, db *sql.DB, userID, adminID string) string { + t.Helper() + var id string + err := db.QueryRow( + `INSERT INTO datarights.deletion_jobs (user_id, admin_user_id) + VALUES ($1, $2) RETURNING id::text`, userID, adminID, + ).Scan(&id) + if err != nil { + t.Fatalf("seeding deletion job: %v", err) + } + return id +} + +// runCleanupSQL reads and executes the exact shipping cleanup.sql file. It has +// no parameters, so lib/pq runs the whole BEGIN; ... COMMIT; as one statement. +func runCleanupSQL(t *testing.T, db *sql.DB) { + t.Helper() + content, err := os.ReadFile(cleanupSQLPath) + if err != nil { + t.Fatalf("reading cleanup.sql at %s: %v", cleanupSQLPath, err) + } + if _, err := db.Exec(string(content)); err != nil { + t.Fatalf("executing cleanup.sql: %v", err) + } +} + +// snapshot captures the owner-scoped counts the idempotency check compares. +func snapshot(t *testing.T, db *sql.DB, adminID, userID, auditID string) map[string]int { + t.Helper() + s := make(map[string]int) + for _, table := range targetTables { + s[table+"|admin"] = countByUser(t, db, table, adminID) + s[table+"|user"] = countByUser(t, db, table, userID) + } + s["auth.users|admin"] = queryCount(t, db, + "SELECT count(*) FROM auth.users WHERE id = $1 AND role = 'admin'", adminID) + s["datarights.deletion_jobs|audit"] = queryCount(t, db, + "SELECT count(*) FROM datarights.deletion_jobs WHERE id = $1", auditID) + return s +} + +// deleteUserData removes everything a seeded user owns plus the user row, so a +// crashed prior run never leaves rows that break the next one. +func deleteUserData(db *sql.DB, id string) { + for _, stmt := range []string{ + "DELETE FROM finance.pro_rata_schedules WHERE user_id = $1", + "DELETE FROM finance.tags WHERE user_id = $1", + "DELETE FROM finance.budget_periods WHERE user_id = $1", + "DELETE FROM finance.default_settings WHERE user_id = $1", + "DELETE FROM datarights.export_jobs WHERE user_id = $1", + "DELETE FROM datarights.deletion_jobs WHERE user_id = $1 OR admin_user_id = $1", + "DELETE FROM auth.users WHERE id = $1", + } { + _, _ = db.Exec(stmt, id) + } +} + +// countByUser counts rows a user owns in the given fully-qualified table. The +// table name comes from targetTables (this package), never external input. +func countByUser(t *testing.T, db *sql.DB, table, userID string) int { + t.Helper() + return queryCount(t, db, "SELECT count(*) FROM "+table+" WHERE user_id = $1", userID) +} + +func queryCount(t *testing.T, db *sql.DB, query string, args ...any) int { + t.Helper() + var n int + if err := db.QueryRow(query, args...).Scan(&n); err != nil { + t.Fatalf("count query failed: %v\nquery: %s", err, query) + } + return n +} + +func mustExec(t *testing.T, db *sql.DB, query string, args ...any) { + t.Helper() + if _, err := db.Exec(query, args...); err != nil { + t.Fatalf("exec failed: %v\nquery: %s", err, query) + } +} + +// withParams returns baseURL with the given query params set (added or replaced). +func withParams(t *testing.T, baseURL string, params map[string]string) string { + t.Helper() + parsed, err := url.Parse(baseURL) + if err != nil { + t.Fatalf("parsing TEST_DATABASE_URL: %v", err) + } + q := parsed.Query() + for k, v := range params { + q.Set(k, v) + } + parsed.RawQuery = q.Encode() + return parsed.String() +} + +// stripParams returns baseURL with the given query params removed. +func stripParams(t *testing.T, baseURL string, keys ...string) string { + t.Helper() + parsed, err := url.Parse(baseURL) + if err != nil { + t.Fatalf("parsing TEST_DATABASE_URL: %v", err) + } + q := parsed.Query() + for _, k := range keys { + q.Del(k) + } + parsed.RawQuery = q.Encode() + return parsed.String() +} From 624de1094e4e3547cb37a36fb403376d54cb240d Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:14:50 +0100 Subject: [PATCH 15/70] feat(cm): add change-management project skill --- .pi/skills/change-management/SKILL.md | 243 ++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 .pi/skills/change-management/SKILL.md diff --git a/.pi/skills/change-management/SKILL.md b/.pi/skills/change-management/SKILL.md new file mode 100644 index 00000000..186c8d3c --- /dev/null +++ b/.pi/skills/change-management/SKILL.md @@ -0,0 +1,243 @@ +--- +name: change-management +description: > + GoFin change-management framework. Activate when creating, executing, or + reviewing an operational/destructive change under change-management/ (e.g. + one-off data cleanups, manual prod procedures). Covers folder naming, the + description/preflight/steps files, the activity/validation pairing rule, the + validator and execution-log generator, and the completion (status suffix + + execution log) workflow. +--- + +# Change Management + +The `change-management/` directory (repo root) is a durable, auditable framework +for delivering operational changes that are NOT ordinary code migrations: +destructive data operations and manual production procedures. Each change is a +numbered, immutable-ish record that accrues history, modeled on how ADRs are +organized. + +This skill is the how-to. The copyable file formats live in +`change-management/000-template/`; read those files rather than trusting a +paraphrase here, and copy them to start a new item. + +## When to Use Change Management + +Use a CM item when the change is a **managed manual operation**, for example: + +- A destructive one-off data operation (bulk `DELETE`/`UPDATE` against prod). +- A manual production procedure with a blast radius that needs a documented + rollback and an auditable record of who ran it and what happened. +- Anything where "just run the migration" is unsafe because the action cannot + be trivially reversed and needs a human executing checklists with validations. + +Use a **normal migration** (e.g. `services/dbmigrate`) instead when the change +is idempotent, safely automatable, and reversible through ordinary +forward/backward migration tooling. If a golang-migrate migration expresses the +change safely, it does not belong under `change-management/`. + +Rule of thumb: if you need a rollback plan written per step and a technician +signing off on validations, it is a CM item. If CI can apply and revert it +mechanically, it is a migration. + +## Directory Layout + +``` +change-management/ +├── .validation/ +│ └── validate_change_management.py # template + status validator (CI + local) +├── .tools/ +│ └── generate_execution_log.py # builds execution-log.md from preflight.md + steps.md +├── 000-template/ # reserved: the canonical template to copy +│ ├── description.md +│ ├── preflight.md +│ ├── steps.md +│ └── assets/ # optional supporting files (.gitkeep keeps it tracked) +└── <NNN>_<kebab-title>[_<status>]/ # one folder per change item + ├── description.md + ├── preflight.md + ├── steps.md + ├── execution-log.md # present once executed (completion PR) + └── assets/ # optional (scripts, images, SQL, ...) +``` + +- `.validation/` and `.tools/` are dot-prefixed and are NOT treated as items by + the validator. +- `000-template` is the reserved literal (hyphen). It is not an executable item; + the validator checks its file structure but skips status/log checks. + +## Naming Convention + +| Element | Rule | +|---------|------| +| Item folder (pre-completion) | `<NNN>_<kebab-title>`; `NNN` = zero-padded sequential id, `<kebab-title>` = lowercase kebab-case. Regex: `^\d{3}_[a-z0-9]+(-[a-z0-9]+)*$` | +| Item folder (completed) | `<NNN>_<kebab-title>_<status>` where `status ∈ {completed, completed-off-script, failed, aborted}` | +| Template | The reserved literal `000-template` (hyphen) | +| Tooling dirs | `.validation/` and `.tools/` (dot-prefixed, ignored as items) | + +Ids are assigned sequentially by the author: pick the next unused number. The +template is `000`; the first real item is `001`. The per-folder regex does not +auto-enforce uniqueness, so confirm your id is not already taken before +creating. + +## Creating an Item + +1. Pick the next unused sequential id (`NNN`) and a lowercase kebab-case title. +2. Copy the template: + + ``` + cp -r change-management/000-template change-management/<NNN>_<kebab-title> + ``` + +3. Fill in `description.md`, `preflight.md`, and `steps.md`. Do not add, remove, + or rename the enforced headings. Replace every `<...>` placeholder. +4. Put any supporting files (SQL, scripts, images) under the item's `assets/`. + `assets/` contents are not validated. +5. Do NOT create `execution-log.md` yet and do NOT add a status suffix: those + arrive at completion. +6. Run the validator locally (below) and open the item-creation PR. + +## The Three Required Files + +Every item folder must contain `description.md`, `preflight.md`, and `steps.md`. +The authoritative formats are the files in `change-management/000-template/`: +read and copy those. Their roles and enforced shapes: + +### `description.md` — rationale and risk assessment + +A fixed section structure the validator enforces. It must contain these `##` +sections, each with its `####` prompts (see `000-template/description.md` for +the full prompt wording): + +- `## Change Event` (5 prompts: purpose, what's required to execute, expected + end state, assumptions about system state, rollout date/time + duration) +- `## Impact / Risk Assessment` (5 prompts: why necessary, why under CM / can it + be automated, prerequisite changes, intrusiveness + impacted teams/services, + how it was tested for prod safety) +- `## Worst Case Scenario` (2 prompts: worst realistic failure, how this CM + mitigates it) +- `## Rollback Procedure` (3 prompts: rollback triggers, actions to reach a + known-good state, whether rollback was rehearsed in a dev environment) + +Do not add, remove, or rename these headings. + +### `preflight.md` — everything before execution + +Merge, deploy, dry-run, backups: the work done before the change is executed. + +### `steps.md` — the execution itself + +The change action plus any repo housekeeping that finalizes the change +(committing the execution log, renaming the folder with a status suffix). + +Both `preflight.md` and `steps.md` use the paired activity/validation structure +below. + +## Activity / Validation Pairing Rule + +This is the framework's core discipline: **no action ships without a defined way +to confirm it and a rollback if the confirmation fails.** + +- Every `# Activity N` MUST be immediately followed by a matching + `# Validation N` with the same index. +- Keep indexes contiguous, starting at 1. +- Each block (Activity and Validation alike) MUST contain: + - a `**Description**` line, + - a `## Checklist:` section, + - a `## Rollback Plan:` section. + +If an `Activity N` has no matching `Validation N`, the validator fails. See +`change-management/000-template/preflight.md` and `steps.md` for the exact +block layout; add or remove pairs as needed. + +## Status Lifecycle + +``` + (author creates item) (technician executes) (completion PR) +000-template ─copy─▶ NNN_title ───────────────────────▶ NNN_title_<status> + no status suffix execution-log.md required + execution-log.md not required +``` + +| Status | Meaning | +|--------|---------| +| `completed` | All activities executed as written; all validations passed. | +| `completed-off-script` | Completed, but the technician deviated from the written steps; deviations documented in `execution-log.md`. | +| `failed` | Execution could not be completed successfully; state and follow-up documented. | +| `aborted` | Execution was stopped before completion (e.g. a preflight validation failed) and the system was returned to a known-good state. | + +## Running the Validator + +`change-management/.validation/validate_change_management.py` is a Python 3 CLI +that runs locally and in CI. It validates folder names, required files, +`description.md` headings, the activity/validation pairing, and completion +evidence. + +``` +# validate every item +python change-management/.validation/validate_change_management.py + +# validate a single item +python change-management/.validation/validate_change_management.py --item <NNN>_<kebab-title> + +# non-default root (rarely needed) +python change-management/.validation/validate_change_management.py --root change-management +``` + +- Exit `0`: all validated items conform. +- Exit `1`: a violation; it prints the item, file, and failing rule. + +Run it before opening either PR (item creation and completion). CI runs the same +command on every push/PR via the `validate-change-management` job in +`.github/workflows/ci.yml`, so a violation blocks merge. + +## Generating the Execution Log + +`change-management/.tools/generate_execution_log.py` reads an item's +`preflight.md` and `steps.md` and writes `execution-log.md` into the item +folder. The generated log is the checklist the technician fills in during +execution and commits as evidence. + +``` +# generate the log for an item +python change-management/.tools/generate_execution_log.py change-management/<NNN>_<kebab-title> + +# overwrite an existing log +python change-management/.tools/generate_execution_log.py change-management/<NNN>_<kebab-title> --force +``` + +It refuses to overwrite an existing `execution-log.md` unless `--force` is +passed. Generate the log at execution time, not at item creation. + +## Completion Workflow + +When the change has been executed: + +1. **Generate the execution log** if you have not already + (`generate_execution_log.py <item-folder>`). +2. **Fill it in** during execution: technician, date/time, environment, each + activity/validation checkbox, and Comments for any off-script actions, log + output, or anomalies. Record the outcome. +3. **Rename the folder** with the outcome status suffix: + + ``` + git mv change-management/<NNN>_<kebab-title> change-management/<NNN>_<kebab-title>_<status> + ``` + + where `<status>` is one of `completed | completed-off-script | failed | + aborted`. +4. **Open the completion PR** with the rename and the filled-in + `execution-log.md`. CI re-validates: a status-suffixed folder must contain a + non-empty `execution-log.md`, or the `validate-change-management` job fails + and merge is blocked. + +## Common Failures + +| Case | Result | +|------|--------| +| An `Activity N` has no matching `Validation N` | Validator fails: unmatched activity. | +| Folder renamed with an invalid status suffix | Folder-name regex fails. | +| Completed folder missing `execution-log.md` | Validator fails (status suffix requires the log). | +| Wrong id padding (e.g. `1_foo`) | Folder-name regex fails (`\d{3}` required). | +| Empty `assets/` | Allowed; keep it tracked with `.gitkeep`. | +| Two items reuse an id | Not auto-enforced; assign ids sequentially and check first. | From f2e2bc3acc47cec89359836ac58973fd3ef1d71e Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:15:57 +0100 Subject: [PATCH 16/70] feat(shell): land admin on /admin after login via getLandingPath 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. --- .../shell/app/features/auth/hooks/useLoginForm.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts b/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts index 9b297164..96e29162 100644 --- a/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts +++ b/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, type FormEvent } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { useAuthStore } from "@/stores/auth-store"; import { consumeReturnToPath, useFormMutation } from "@gofin/api"; -import { validateEmail } from "@gofin/core"; +import { getLandingPath, validateEmail } from "@gofin/core"; /** Grouped login credential fields. */ export interface LoginCredentials { @@ -28,7 +28,7 @@ const INITIAL_CREDENTIALS: LoginCredentials = { }; export function useLoginForm(): { state: LoginFormState; actions: LoginFormActions } { - const { login, isAuthenticated, isLoading, checkAuth } = useAuthStore(); + const { login, isAuthenticated, isLoading, checkAuth, user } = useAuthStore(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -42,10 +42,10 @@ export function useLoginForm(): { state: LoginFormState; actions: LoginFormActio }, [checkAuth]); useEffect(() => { - if (!isLoading && isAuthenticated) { - navigate("/dashboard"); + if (!isLoading && isAuthenticated && user) { + navigate(getLandingPath(user)); } - }, [isLoading, isAuthenticated, navigate]); + }, [isLoading, isAuthenticated, user, navigate]); const setField = useCallback( (key: keyof LoginCredentials, value: string) => { @@ -63,7 +63,7 @@ export function useLoginForm(): { state: LoginFormState; actions: LoginFormActio } else if (returnTo && returnTo !== "/login" && returnTo !== "/register") { navigate(returnTo); } else { - navigate("/dashboard"); + navigate(getLandingPath(user)); } }, }); From aa5cf41455c6ffd4c2e6f7fb09106030f17113f9 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:15:59 +0100 Subject: [PATCH 17/70] test(shell): assert login navigation for admin vs user 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. --- .../features/auth/__tests__/login.test.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx b/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx index b6b14832..b2d19f9e 100644 --- a/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx +++ b/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx @@ -40,6 +40,7 @@ async function renderLoginPage(options?: { route?: string; searchParams?: Record routeConfig: [ { path: "/login", element: <LoginPage /> }, { path: "/dashboard", element: <div>Dashboard page</div> }, + { path: "/admin", element: <div>Admin page</div> }, { path: "/onboarding", element: <div>Onboarding page</div> }, { path: "/expenses", element: <div>Expenses page</div> }, ], @@ -126,6 +127,22 @@ describe("login page", () => { }); }); + it("redirects to /admin after an admin logs in", async () => { + const admin = buildUser({ role: "admin", hasCompletedOnboarding: true }); + setupUnauthenticatedMock({ + "/api/auth/login": { user: admin }, + }); + await renderLoginPage(); + + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "admin@example.com" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + + await waitFor(() => { + expect(screen.getByText("Admin page")).toBeInTheDocument(); + }); + }); + it("redirects to /onboarding for non-onboarded user", async () => { const user = buildUser({ hasCompletedOnboarding: false }); setupUnauthenticatedMock({ @@ -195,5 +212,21 @@ describe("login page", () => { expect(screen.getByText("Dashboard page")).toBeInTheDocument(); }); }); + + it("redirects to /admin when an admin is already authenticated", async () => { + const admin = buildUser({ role: "admin", hasCompletedOnboarding: true }); + resetStore({ isLoading: false, isAuthenticated: true, user: admin }); + + const mockFetch = createMockApi({ + "/api/auth/me": { user: admin }, + }); + global.fetch = mockFetch; + + await renderLoginPage(); + + await waitFor(() => { + expect(screen.getByText("Admin page")).toBeInTheDocument(); + }); + }); }); }); From ddd700365bae21feb0bddfa4ee9d64f25382f4ed Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:16:14 +0100 Subject: [PATCH 18/70] feat(settings): derive tabs and sections from role 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". --- .../src/features/settings/SettingsFeature.tsx | 71 ++++------------ .../src/features/settings/settingsTabs.tsx | 83 +++++++++++++++++++ 2 files changed, 98 insertions(+), 56 deletions(-) create mode 100644 frontend/apps/finance/src/features/settings/settingsTabs.tsx diff --git a/frontend/apps/finance/src/features/settings/SettingsFeature.tsx b/frontend/apps/finance/src/features/settings/SettingsFeature.tsx index e784fe73..c7d8d6ff 100644 --- a/frontend/apps/finance/src/features/settings/SettingsFeature.tsx +++ b/frontend/apps/finance/src/features/settings/SettingsFeature.tsx @@ -1,63 +1,22 @@ import { useState, useCallback } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@gofin/ui/components/card"; -import { - Settings, - Wallet, - UserRound, - Lock, - Tags, -} from "lucide-react"; +import { Settings } from "lucide-react"; import type { SettingsPageProps } from "../../types"; -import { DefaultBudgetSection } from "./components/DefaultBudgetSection"; -import { ProfileSection } from "./components/ProfileSection"; -import { PasswordSection } from "./components/PasswordSection"; -import { TagsSection } from "./components/TagsSection"; -import { ExportDataSection } from "./components/ExportDataSection"; - -type SettingsTab = "budget" | "profile" | "password" | "tags"; - -interface TabDefinition { - id: SettingsTab; - label: string; - icon: typeof Wallet; -} - -const TABS: TabDefinition[] = [ - { id: "budget", label: "Default Budget", icon: Wallet }, - { id: "profile", label: "Profile", icon: UserRound }, - { id: "password", label: "Password", icon: Lock }, - { id: "tags", label: "Tags", icon: Tags }, -]; +import { getSettingsTabs, type SettingsTabId } from "./settingsTabs"; export function SettingsFeature({ user, onUserUpdated }: SettingsPageProps) { - const [activeTab, setActiveTab] = useState<SettingsTab>("budget"); - const [expandedAccordion, setExpandedAccordion] = useState<SettingsTab | null>("budget"); + const tabList = getSettingsTabs(user); + const defaultTabId = tabList[0].id; - const toggleAccordion = useCallback((tab: SettingsTab) => { + const [activeTab, setActiveTab] = useState<SettingsTabId>(defaultTabId); + const [expandedAccordion, setExpandedAccordion] = useState<SettingsTabId | null>(defaultTabId); + + const toggleAccordion = useCallback((tab: SettingsTabId) => { setExpandedAccordion((prev) => (prev === tab ? null : tab)); }, []); - const renderSection = useCallback( - (tab: SettingsTab) => { - switch (tab) { - case "budget": - return <DefaultBudgetSection user={user} />; - case "profile": - return ( - <> - <ProfileSection user={user} onUserUpdated={onUserUpdated} /> - <hr className="my-6 border-border" /> - <ExportDataSection /> - </> - ); - case "password": - return <PasswordSection onUserUpdated={onUserUpdated} />; - case "tags": - return <TagsSection />; - } - }, - [user, onUserUpdated], - ); + const sectionProps = { user, onUserUpdated }; + const activeDefinition = tabList.find((tab) => tab.id === activeTab); return ( <div className="space-y-4"> @@ -69,7 +28,7 @@ export function SettingsFeature({ user, onUserUpdated }: SettingsPageProps) { {/* Desktop: tabbed layout */} <div className="hidden md:flex gap-6"> <div className="flex flex-col gap-1 min-w-[180px]"> - {TABS.map((tab) => { + {tabList.map((tab) => { const Icon = tab.icon; const isActive = activeTab === tab.id; return ( @@ -92,15 +51,15 @@ export function SettingsFeature({ user, onUserUpdated }: SettingsPageProps) { <Card className="flex-1"> <CardHeader> - <CardTitle>{TABS.find((tab) => tab.id === activeTab)?.label}</CardTitle> + <CardTitle>{activeDefinition?.label}</CardTitle> </CardHeader> - <CardContent>{renderSection(activeTab)}</CardContent> + <CardContent>{activeDefinition?.render(sectionProps)}</CardContent> </Card> </div> {/* Mobile: accordion sections */} <div className="flex flex-col gap-2 md:hidden"> - {TABS.map((tab) => { + {tabList.map((tab) => { const Icon = tab.icon; const isExpanded = expandedAccordion === tab.id; return ( @@ -120,7 +79,7 @@ export function SettingsFeature({ user, onUserUpdated }: SettingsPageProps) { </button> {isExpanded && ( <CardContent className="border-t pt-4"> - {renderSection(tab.id)} + {tab.render(sectionProps)} </CardContent> )} </Card> diff --git a/frontend/apps/finance/src/features/settings/settingsTabs.tsx b/frontend/apps/finance/src/features/settings/settingsTabs.tsx new file mode 100644 index 00000000..2bf8355a --- /dev/null +++ b/frontend/apps/finance/src/features/settings/settingsTabs.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from "react"; +import { Wallet, UserRound, Lock, Tags } from "lucide-react"; +import type { User } from "@gofin/core"; +import { canUseFinanceFeatures } from "@gofin/core"; +import { DefaultBudgetSection } from "./components/DefaultBudgetSection"; +import { ProfileSection } from "./components/ProfileSection"; +import { PasswordSection } from "./components/PasswordSection"; +import { TagsSection } from "./components/TagsSection"; +import { ExportDataSection } from "./components/ExportDataSection"; + +export type SettingsTabId = "budget" | "profile" | "password" | "tags"; + +/** Props every section render receives; each render uses only what it needs. */ +export interface SettingsSectionProps { + user: User; + onUserUpdated?: () => void; +} + +export interface SettingsTabDefinition { + id: SettingsTabId; + label: string; + icon: typeof Wallet; + render: (props: SettingsSectionProps) => ReactNode; +} + +// Shared, finance-agnostic tabs. The Profile tab here renders ProfileSection +// alone: it is the admin-safe variant (username/email only, no finance inputs). +const profileTab: SettingsTabDefinition = { + id: "profile", + label: "Profile", + icon: UserRound, + render: ({ user, onUserUpdated }) => ( + <ProfileSection user={user} onUserUpdated={onUserUpdated} /> + ), +}; + +const passwordTab: SettingsTabDefinition = { + id: "password", + label: "Password", + icon: Lock, + render: ({ onUserUpdated }) => <PasswordSection onUserUpdated={onUserUpdated} />, +}; + +// Admin (operator) tabs: Profile + Password only. The finance-only sections +// (DefaultBudgetSection, TagsSection, ExportDataSection) are never referenced +// here, so they are never rendered or called on the admin render path. +export const adminTabs: SettingsTabDefinition[] = [profileTab, passwordTab]; + +// Regular-user tabs: unchanged from the pre-refactor behavior. The finance-only +// sections are referenced exclusively by these definitions; the Profile tab +// additionally renders the data-export section. +export const userTabs: SettingsTabDefinition[] = [ + { + id: "budget", + label: "Default Budget", + icon: Wallet, + render: ({ user }) => <DefaultBudgetSection user={user} />, + }, + { + id: "profile", + label: "Profile", + icon: UserRound, + render: ({ user, onUserUpdated }) => ( + <> + <ProfileSection user={user} onUserUpdated={onUserUpdated} /> + <hr className="my-6 border-border" /> + <ExportDataSection /> + </> + ), + }, + passwordTab, + { + id: "tags", + label: "Tags", + icon: Tags, + render: () => <TagsSection />, + }, +]; + +/** Role-derived tab list. Admins get the operator subset; users get the full set. */ +export function getSettingsTabs(user: User): SettingsTabDefinition[] { + return canUseFinanceFeatures(user) ? userTabs : adminTabs; +} From 6354d1701df4b171f4e4b372adf548b0d2e2a924 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:17:17 +0100 Subject: [PATCH 19/70] test(gateway): add route coverage guardrail test 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. --- .../gateway/internal/access/coverage_test.go | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 services/gateway/internal/access/coverage_test.go diff --git a/services/gateway/internal/access/coverage_test.go b/services/gateway/internal/access/coverage_test.go new file mode 100644 index 00000000..29dbf345 --- /dev/null +++ b/services/gateway/internal/access/coverage_test.go @@ -0,0 +1,138 @@ +package access + +import "testing" + +// Route coverage guardrail. +// +// This file is a HAND-MAINTAINED enumeration of every known gateway route +// class. For each one it asserts two things: the route resolves to its intended +// access level, and that level comes from an explicit rule rather than the +// fail-safe Default (Authenticated). It turns "someone added a route but forgot +// to classify it" into a CI failure instead of an accidental exposure. +// +// It is intentionally separate from resolver_test.go: that suite proves +// precedence correctness (exact > longest prefix > default); this suite proves +// surface coverage (every real route is classified by a rule). +// +// Residual risk: because the enumeration is maintained by hand, it only guards +// routes someone remembered to list here. The whole-service prefixes +// (/api/finance, /api/expenses, /api/datarights/exports, /api/datarights/deletions, +// /api/admin) already cover every subpath beneath them, so new endpoints in +// those services are classified automatically. The real exposure is a NEW +// mixed-group route added under /api/auth or /api/datarights (the only groups +// that mix access levels): such a route must be added to BOTH the policy table +// (policy.go) and this enumeration. Forgetting the policy entry makes this test +// fail (the route resolves to the Default rather than its intended level); +// forgetting to also list it here is the residual gap this guardrail cannot +// close. + +// sentinelDefault is an Access value that no rule in DefaultPolicy() ever +// returns. Resolving a route under a policy whose Default is this sentinel lets +// the tests below tell "classified by a rule" apart from "fell through to the +// Default": a rule-classified route ignores Default and resolves identically +// under any Default, while an unclassified route returns whatever Default is +// configured. +const sentinelDefault Access = -1 + +// classifiedByRule reports whether method+path is matched by an explicit rule +// in DefaultPolicy(), as opposed to falling through to the policy Default. +// +// It resolves the route twice against policies that are identical except for +// their Default value. If a rule matches, both resolutions return that rule's +// level and agree; if no rule matches, each returns its own Default and they +// disagree. This distinguishes an explicit classification from the fail-safe +// fallback without changing the access package, which matters for routes whose +// intended level (Authenticated) happens to equal the real Default. +func classifiedByRule(method, path string) bool { + withRealDefault := DefaultPolicy() + withSentinelDefault := DefaultPolicy() + withSentinelDefault.Default = sentinelDefault + return withRealDefault.resolve(method, path) == withSentinelDefault.resolve(method, path) +} + +// TestCoverage_KnownRoutesResolveToExplicitLevel enumerates every known gateway +// route class and asserts each resolves to its intended, rule-classified access +// level. See the file header for the hand-maintained-enumeration caveat and its +// residual risk. +// +// For method-agnostic prefix classes (/api/admin, /api/datarights/*, +// /api/finance, /api/expenses) the method column carries a representative real +// method; classification is method-independent for those (proven in +// resolver_test.go), so any method resolves the same. +func TestCoverage_KnownRoutesResolveToExplicitLevel(t *testing.T) { + policy := DefaultPolicy() + + cases := []struct { + name string + method string + path string + want Access + }{ + // Public: reachable with no token. + {"register", "POST", "/api/auth/register", Public}, + {"login", "POST", "/api/auth/login", Public}, + {"refresh", "POST", "/api/auth/refresh", Public}, + {"health", "GET", "/health", Public}, + {"metrics", "GET", "/metrics", Public}, + + // Authenticated: any valid token, no role check. These live in the + // mixed /api/auth group, so their intended level equals the Default; + // the classifiedByRule assertion below is what proves each is + // rule-classified rather than a silent fall-through. + {"me (GET)", "GET", "/api/auth/me", Authenticated}, + {"me (PUT)", "PUT", "/api/auth/me", Authenticated}, + {"me password", "POST", "/api/auth/me/password", Authenticated}, + {"logout", "POST", "/api/auth/logout", Authenticated}, + {"restore", "POST", "/api/auth/restore", Authenticated}, + + // Admin: operator-only. + {"assume", "POST", "/api/auth/assume", Admin}, + {"admin group", "GET", "/api/admin", Admin}, + {"datarights deletions", "DELETE", "/api/datarights/deletions", Admin}, + + // Personal: valid token acting as a regular user. + {"onboarding-complete", "POST", "/api/auth/onboarding-complete", Personal}, + {"finance group", "GET", "/api/finance", Personal}, + {"expenses group", "GET", "/api/expenses", Personal}, + {"datarights exports", "POST", "/api/datarights/exports", Personal}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := policy.resolve(tc.method, tc.path); got != tc.want { + t.Errorf("resolve(%q, %q) = %s, want %s", tc.method, tc.path, got, tc.want) + } + if !classifiedByRule(tc.method, tc.path) { + t.Errorf("route %s %q is not classified by an explicit rule; it falls through to the policy Default (%s). Add a matching rule in policy.go.", tc.method, tc.path, policy.Default) + } + }) + } +} + +// TestCoverage_UnclassifiedRouteFallsThroughToDefault demonstrates the +// guardrail's failure mode, satisfying the acceptance criterion that adding a +// personal- or admin-intended route with no matching policy entry causes the +// coverage test to fail. +// +// A route with no rule resolves to the fail-safe Default (Authenticated) and is +// not classified by a rule. If such a route were added to the enumeration above +// with its intended non-default level (Personal or Admin), the want assertion +// would fail (got Authenticated), surfacing the missing classification in CI. +func TestCoverage_UnclassifiedRouteFallsThroughToDefault(t *testing.T) { + policy := DefaultPolicy() + + // A hypothetical new downstream route with no matching policy entry. + const method, path = "GET", "/api/newservice/records" + + if classifiedByRule(method, path) { + t.Fatalf("expected %q to be unclassified, but a rule matched it", path) + } + if got := policy.resolve(method, path); got != policy.Default { + t.Errorf("unclassified route should fall to Default %s, got %s", policy.Default, got) + } + // Because the Default is Authenticated, an intended-Personal or -Admin route + // left unclassified would fail the coverage table's want assertion. + if got := policy.resolve(method, path); got == Personal || got == Admin { + t.Errorf("unclassified route should not resolve to a role-gated level, got %s", got) + } +} From b0595e041d861296f25d6634976621f796c0d948 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:17:42 +0100 Subject: [PATCH 20/70] test(settings): cover role-derived admin composition 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. --- .../__tests__/SettingsFeature-admin.test.tsx | 81 +++++++++++++++++++ .../settings/__tests__/settingsTabs.test.ts | 41 ++++++++++ 2 files changed, 122 insertions(+) create mode 100644 frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx create mode 100644 frontend/apps/finance/src/features/settings/__tests__/settingsTabs.test.ts diff --git a/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx b/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx new file mode 100644 index 00000000..cbdf3e74 --- /dev/null +++ b/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { SettingsFeature } from "@/features/settings"; +import { buildUser } from "@gofin/test-utils"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const adminUser = buildUser({ + role: "admin", + username: "operator", + email: "operator@example.com", +}); + +function renderAdminSettings() { + return render( + <MemoryRouter> + <SettingsFeature user={adminUser} onUserUpdated={vi.fn()} /> + </MemoryRouter>, + ); +} + +describe("SettingsFeature - admin composition", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("renders exactly the Profile and Password tabs", () => { + renderAdminSettings(); + + expect(screen.getByText("Settings")).toBeInTheDocument(); + expect(screen.getAllByText("Profile").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Password").length).toBeGreaterThanOrEqual(1); + + // Finance-only tabs are absent from the admin tab list. + expect(screen.queryByText("Default Budget")).not.toBeInTheDocument(); + expect(screen.queryByText("Tags")).not.toBeInTheDocument(); + }); + + it("defaults to the Profile tab, showing the profile form on mount", () => { + renderAdminSettings(); + + // Profile is tabList[0] for an admin, so its fields render without any + // click. Both the desktop card and the default-expanded mobile accordion + // render the section, hence getAllByLabelText. + const usernameInputs = screen.getAllByLabelText("Username") as HTMLInputElement[]; + expect(usernameInputs.length).toBeGreaterThanOrEqual(1); + expect(usernameInputs[0].value).toBe("operator"); + + const emailInputs = screen.getAllByLabelText("Email") as HTMLInputElement[]; + expect(emailInputs[0].value).toBe("operator@example.com"); + }); + + it("does not render the Data Export section on the Profile tab", () => { + renderAdminSettings(); + + expect(screen.queryByText("Data Export")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /export my data/i }), + ).not.toBeInTheDocument(); + }); + + it("does not render Default Budget or Tags sections or their inputs", () => { + renderAdminSettings(); + + expect(screen.queryByLabelText("Monthly Budget")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /save defaults/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText("New tag name")).not.toBeInTheDocument(); + }); + + it("issues no finance API calls for an admin (sections never mount)", () => { + renderAdminSettings(); + + // DefaultBudget, Tags, and Export sections all fetch on mount. None are on + // the admin render path, so no request is made. + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/apps/finance/src/features/settings/__tests__/settingsTabs.test.ts b/frontend/apps/finance/src/features/settings/__tests__/settingsTabs.test.ts new file mode 100644 index 00000000..9aaafc9a --- /dev/null +++ b/frontend/apps/finance/src/features/settings/__tests__/settingsTabs.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { buildUser } from "@gofin/test-utils"; +import { getSettingsTabs, adminTabs, userTabs } from "@/features/settings/settingsTabs"; + +describe("settingsTabs", () => { + it("exposes admin tabs as exactly [Profile, Password]", () => { + expect(adminTabs.map((tab) => tab.id)).toEqual(["profile", "password"]); + expect(adminTabs.map((tab) => tab.label)).toEqual(["Profile", "Password"]); + }); + + it("exposes user tabs as [Default Budget, Profile, Password, Tags]", () => { + expect(userTabs.map((tab) => tab.id)).toEqual([ + "budget", + "profile", + "password", + "tags", + ]); + }); + + it("selects the admin subset when the user cannot use finance features", () => { + const tabs = getSettingsTabs(buildUser({ role: "admin" })); + expect(tabs).toBe(adminTabs); + expect(tabs.map((tab) => tab.id)).toEqual(["profile", "password"]); + }); + + it("selects the full user set for a regular user", () => { + const tabs = getSettingsTabs(buildUser({ role: "user" })); + expect(tabs).toBe(userTabs); + expect(tabs.map((tab) => tab.id)).toEqual([ + "budget", + "profile", + "password", + "tags", + ]); + }); + + it("defaults to Profile for admins and Default Budget for users", () => { + expect(getSettingsTabs(buildUser({ role: "admin" }))[0].id).toBe("profile"); + expect(getSettingsTabs(buildUser({ role: "user" }))[0].id).toBe("budget"); + }); +}); From eb0b169c95f09bc91005148c6ce725953fb9f8e8 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:18:29 +0100 Subject: [PATCH 21/70] feat(cm): add execution-log generator --- .../.tools/generate_execution_log.py | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100755 change-management/.tools/generate_execution_log.py diff --git a/change-management/.tools/generate_execution_log.py b/change-management/.tools/generate_execution_log.py new file mode 100755 index 00000000..8a0550b4 --- /dev/null +++ b/change-management/.tools/generate_execution_log.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Generate a fillable ``execution-log.md`` for a change-management item. + +Reads a change-management item's ``preflight.md`` and ``steps.md`` (the paired +``# Activity N`` / ``# Validation N`` structure defined in spec section 6) and +renders an ``execution-log.md``: a per-activity/validation checklist the +technician ticks off during execution and commits as the item's audit record. + +Parse-and-render only, one command, no persistent state. + + usage: generate_execution_log.py <item-folder> [--force] + writes <item-folder>/execution-log.md (refuses to overwrite unless --force) +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import tempfile +from dataclasses import dataclass, field +from pathlib import Path + +# A block heading, e.g. "# Activity 1: merge the PR" or "# Validation 2: CI green". +_HEADING_RE = re.compile(r"^#\s+(Activity|Validation)\s+(\d+)\s*:\s*(.*)$") +# A checklist item: numbered ("1. foo", "2) bar") or bulleted ("- foo", "* bar"). +_LIST_ITEM_RE = re.compile(r"^\s*(?:\d+[.)]|[-*+])\s+(.*\S)\s*$") +# The literal folder name reserved for the copyable template. +_TEMPLATE_NAME = "000-template" + +VALID_STATUSES = ("completed", "completed-off-script", "failed", "aborted") + + +@dataclass +class Block: + """A parsed ``Activity`` or ``Validation`` block with its checklist steps.""" + + kind: str # "Activity" or "Validation" + index: int + title: str + steps: list[str] = field(default_factory=list) + + +@dataclass +class Pair: + """An ``Activity N`` and its matching ``Validation N`` (if present).""" + + index: int + activity: Block + validation: Block | None + + +def parse_blocks(text: str) -> list[Block]: + """Parse ``Activity``/``Validation`` blocks and their checklist steps. + + Only the ``## Checklist:`` section of each block contributes steps; the + ``**Description**`` line and ``## Rollback Plan:`` section are intentionally + ignored (they are guidance, not runtime checks). Blockquote instruction + lines (``> ...``) never match a heading, so template preamble is skipped. + """ + blocks: list[Block] = [] + current: Block | None = None + in_checklist = False + + for line in text.splitlines(): + heading = _HEADING_RE.match(line) + if heading: + current = Block( + kind=heading.group(1), + index=int(heading.group(2)), + title=heading.group(3).strip(), + ) + blocks.append(current) + in_checklist = False + continue + + if current is None: + continue + + stripped = line.strip() + if stripped.startswith("## "): + # Any level-2 heading toggles checklist capture on only for Checklist:. + in_checklist = stripped.rstrip().lower().startswith("## checklist") + continue + + if in_checklist: + item = _LIST_ITEM_RE.match(line) + if item: + current.steps.append(item.group(1).strip()) + + return blocks + + +def pair_blocks(blocks: list[Block]) -> list[Pair]: + """Group blocks into ordered ``Activity``/``Validation`` pairs by index. + + Iterates activities in the order they appear and attaches the matching + validation by index. A missing validation yields a ``Pair`` with + ``validation=None`` rather than dropping the activity, so the generated log + still surfaces the gap for the technician (the validator, #10, is what + enforces the pairing rule). + """ + validations = {b.index: b for b in blocks if b.kind == "Validation"} + pairs: list[Pair] = [] + for block in blocks: + if block.kind != "Activity": + continue + pairs.append(Pair(index=block.index, activity=block, validation=validations.get(block.index))) + return pairs + + +def _render_steps(steps: list[str]) -> list[str]: + """Render checklist steps as ``- [ ]`` checkboxes (placeholder if empty).""" + if not steps: + return ["- [ ] (no checklist steps defined)"] + return [f"- [ ] {step}" for step in steps] + + +def _render_pairs(pairs: list[Pair]) -> list[str]: + lines: list[str] = [] + for pair in pairs: + activity = pair.activity + lines.append(f"### Activity {activity.index}: {activity.title}") + lines.extend(_render_steps(activity.steps)) + + validation = pair.validation + if validation is not None: + lines.append(f"**Validation {validation.index}: {validation.title}**") + lines.extend(_render_steps(validation.steps)) + else: + lines.append(f"**Validation {pair.index}: (missing: add a Validation {pair.index} block)**") + lines.append("- [ ] (no matching validation defined)") + + lines.append("> Comments: (off-script actions, log output, anomalies)") + lines.append("") + return lines + + +def render_execution_log(item_name: str, preflight_text: str, steps_text: str) -> str: + """Render the full ``execution-log.md`` body for an item. + + Pure: takes the item name and the raw ``preflight.md`` / ``steps.md`` text + and returns the markdown. The header uses a colon rather than an em-dash to + comply with the repo-wide prohibition on em-dashes in tracked files. + """ + lines: list[str] = [ + f"# Execution Log: {item_name}", + "", + "- Technician: ____________________", + "- Date/Time started: ____________________", + "- Environment: ____________________", + "", + "## Preflight", + ] + lines.extend(_render_pairs(pair_blocks(parse_blocks(preflight_text)))) + + lines.append("## Steps") + lines.extend(_render_pairs(pair_blocks(parse_blocks(steps_text)))) + + lines.extend( + [ + "## Outcome", + "- [ ] All activities and validations completed", + "> Notes:", + "", + "---", + "COMPLETION REQUIRED: rename this item's folder to", + f" change-management/{item_name}_<status>", + f"where <status> is one of: {' | '.join(VALID_STATUSES)}", + "Open a PR with the rename and this filled-in execution log. CI will re-validate.", + "", + ] + ) + return "\n".join(lines) + + +def _write_atomic(destination: Path, content: str) -> None: + """Write ``content`` to ``destination`` via a temp file + atomic rename.""" + fd, tmp_path = tempfile.mkstemp(dir=str(destination.parent), prefix=".execution-log.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as tmp_file: + tmp_file.write(content) + os.replace(tmp_path, destination) + except BaseException: + # Leave the destination untouched if anything fails mid-write. + if os.path.exists(tmp_path): + os.unlink(tmp_path) + raise + + +def generate(item_folder: Path, force: bool = False) -> Path: + """Read an item's sources and write ``execution-log.md``. Returns its path. + + Raises ``FileNotFoundError`` if the folder or a required source is missing, + and ``FileExistsError`` if the log already exists and ``force`` is False. + """ + if not item_folder.is_dir(): + raise FileNotFoundError(f"item folder not found: {item_folder}") + + preflight = item_folder / "preflight.md" + steps = item_folder / "steps.md" + for required in (preflight, steps): + if not required.is_file(): + raise FileNotFoundError(f"required source not found: {required}") + + output = item_folder / "execution-log.md" + if output.exists() and not force: + raise FileExistsError(f"{output} already exists (pass --force to overwrite)") + + content = render_execution_log( + item_name=item_folder.name, + preflight_text=preflight.read_text(encoding="utf-8"), + steps_text=steps.read_text(encoding="utf-8"), + ) + _write_atomic(output, content) + return output + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="generate_execution_log.py", + description="Generate a fillable execution-log.md from an item's preflight.md + steps.md.", + ) + parser.add_argument("item_folder", help="path to the change-management item folder") + parser.add_argument( + "--force", + action="store_true", + help="overwrite an existing execution-log.md", + ) + args = parser.parse_args(argv) + + try: + output = generate(Path(args.item_folder), force=args.force) + except (FileNotFoundError, FileExistsError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + print(f"wrote {output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 2d67943bd4a33312ac2803450ed3e98f607492a1 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:18:29 +0100 Subject: [PATCH 22/70] test(cm): cover execution-log generator --- .../.tools/test_generate_execution_log.py | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100755 change-management/.tools/test_generate_execution_log.py diff --git a/change-management/.tools/test_generate_execution_log.py b/change-management/.tools/test_generate_execution_log.py new file mode 100755 index 00000000..660ed49a --- /dev/null +++ b/change-management/.tools/test_generate_execution_log.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Unit tests for ``generate_execution_log.py``. + +Run directly (no external test runner required): + + python3 change-management/.tools/test_generate_execution_log.py + +Sociable tests: they exercise the real parse/render/IO code through the public +functions and assert on observable output, not internal state. +""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import generate_execution_log as gen # noqa: E402 + +# A minimal conforming preflight/steps fixture with two matched pairs. +PREFLIGHT = """# Preflight: 001_demo + +> Instruction blockquote that must be ignored by the parser. +> Every Activity N must have a Validation N. + +# Activity 1: merge the PR + +**Description**: merges the change PR to main. + +## Checklist: +1. approve the PR +2. click merge + +## Rollback Plan: +1. revert the merge commit + +# Validation 1: main is green + +**Description**: confirm CI passed on main. + +## Checklist: +1. CI on main is green + +## Rollback Plan: +1. revert if red +""" + +STEPS = """# Steps: 001_demo + +> Execution and finalization. + +# Activity 1: run cleanup.sql + +**Description**: executes the destructive cleanup. + +## Checklist: +- run the transaction +- verify affected row count + +## Rollback Plan: +1. restore from backup + +# Validation 1: rows gone + +**Description**: confirm the rows are deleted. + +## Checklist: +1. select count returns zero + +## Rollback Plan: +1. restore from backup +""" + + +class ParseBlocksTest(unittest.TestCase): + def test_extracts_activities_and_validations_with_titles(self): + blocks = gen.parse_blocks(PREFLIGHT) + self.assertEqual( + [(b.kind, b.index, b.title) for b in blocks], + [("Activity", 1, "merge the PR"), ("Validation", 1, "main is green")], + ) + + def test_captures_only_checklist_steps_not_rollback_or_description(self): + blocks = gen.parse_blocks(PREFLIGHT) + activity = blocks[0] + self.assertEqual(activity.steps, ["approve the PR", "click merge"]) + # The rollback item ("revert the merge commit") must not leak in. + self.assertNotIn("revert the merge commit", activity.steps) + + def test_supports_bulleted_checklist_items(self): + blocks = gen.parse_blocks(STEPS) + self.assertEqual(blocks[0].steps, ["run the transaction", "verify affected row count"]) + + def test_ignores_blockquote_instruction_lines(self): + blocks = gen.parse_blocks(PREFLIGHT) + titles = [b.title for b in blocks] + self.assertNotIn("Instruction blockquote that must be ignored by the parser.", titles) + + +class PairBlocksTest(unittest.TestCase): + def test_pairs_activity_with_matching_validation(self): + pairs = gen.pair_blocks(gen.parse_blocks(PREFLIGHT)) + self.assertEqual(len(pairs), 1) + self.assertEqual(pairs[0].activity.index, 1) + self.assertIsNotNone(pairs[0].validation) + self.assertEqual(pairs[0].validation.index, 1) + + def test_missing_validation_yields_none_not_dropped_activity(self): + text = "# Activity 1: lonely\n\n## Checklist:\n1. do it\n" + pairs = gen.pair_blocks(gen.parse_blocks(text)) + self.assertEqual(len(pairs), 1) + self.assertIsNone(pairs[0].validation) + + +class RenderExecutionLogTest(unittest.TestCase): + def setUp(self): + self.log = gen.render_execution_log("001_demo", PREFLIGHT, STEPS) + + def test_header_uses_colon_not_em_dash(self): + self.assertIn("# Execution Log: 001_demo", self.log) + self.assertNotIn("\u2014", self.log) # no em-dash anywhere + + def test_header_has_fillable_metadata(self): + self.assertIn("- Technician: ____________________", self.log) + self.assertIn("- Date/Time started: ____________________", self.log) + self.assertIn("- Environment: ____________________", self.log) + + def test_checkbox_per_checklist_step_across_both_sections(self): + # Preflight activity (2) + validation (1); Steps activity (2) + validation (1). + self.assertIn("- [ ] approve the PR", self.log) + self.assertIn("- [ ] click merge", self.log) + self.assertIn("- [ ] CI on main is green", self.log) + self.assertIn("- [ ] run the transaction", self.log) + self.assertIn("- [ ] verify affected row count", self.log) + self.assertIn("- [ ] select count returns zero", self.log) + + def test_activity_followed_by_validation_and_comments(self): + preflight_section = self.log.split("## Steps")[0] + activity_pos = preflight_section.index("### Activity 1: merge the PR") + validation_pos = preflight_section.index("**Validation 1: main is green**") + comments_pos = preflight_section.index("> Comments:", validation_pos) + self.assertLess(activity_pos, validation_pos) + self.assertLess(validation_pos, comments_pos) + + def test_has_both_named_sections(self): + self.assertIn("## Preflight", self.log) + self.assertIn("## Steps", self.log) + + def test_outcome_section_present(self): + self.assertIn("## Outcome", self.log) + self.assertIn("- [ ] All activities and validations completed", self.log) + self.assertIn("> Notes:", self.log) + + def test_completion_footer_names_rename_target_and_statuses(self): + self.assertIn("COMPLETION REQUIRED", self.log) + self.assertIn("change-management/001_demo_<status>", self.log) + for status in ("completed", "completed-off-script", "failed", "aborted"): + self.assertIn(status, self.log) + + +class GenerateIoTest(unittest.TestCase): + def _make_item(self, root: Path) -> Path: + item = root / "001_demo" + item.mkdir() + (item / "preflight.md").write_text(PREFLIGHT, encoding="utf-8") + (item / "steps.md").write_text(STEPS, encoding="utf-8") + return item + + def test_writes_execution_log_into_item_folder(self): + with tempfile.TemporaryDirectory() as tmp: + item = self._make_item(Path(tmp)) + output = gen.generate(item) + self.assertEqual(output, item / "execution-log.md") + self.assertTrue(output.is_file()) + self.assertIn("# Execution Log: 001_demo", output.read_text(encoding="utf-8")) + + def test_refuses_to_overwrite_without_force(self): + with tempfile.TemporaryDirectory() as tmp: + item = self._make_item(Path(tmp)) + gen.generate(item) + with self.assertRaises(FileExistsError): + gen.generate(item) + + def test_force_overwrites_existing_log(self): + with tempfile.TemporaryDirectory() as tmp: + item = self._make_item(Path(tmp)) + output = gen.generate(item) + output.write_text("stale content", encoding="utf-8") + gen.generate(item, force=True) + self.assertIn("# Execution Log: 001_demo", output.read_text(encoding="utf-8")) + + def test_missing_source_raises(self): + with tempfile.TemporaryDirectory() as tmp: + item = Path(tmp) / "001_demo" + item.mkdir() + (item / "preflight.md").write_text(PREFLIGHT, encoding="utf-8") + with self.assertRaises(FileNotFoundError): + gen.generate(item) + + def test_missing_folder_raises(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(FileNotFoundError): + gen.generate(Path(tmp) / "does-not-exist") + + +class CliTest(unittest.TestCase): + def test_main_returns_zero_and_writes(self): + with tempfile.TemporaryDirectory() as tmp: + item = Path(tmp) / "001_demo" + item.mkdir() + (item / "preflight.md").write_text(PREFLIGHT, encoding="utf-8") + (item / "steps.md").write_text(STEPS, encoding="utf-8") + self.assertEqual(gen.main([str(item)]), 0) + self.assertTrue((item / "execution-log.md").is_file()) + # Second run without --force fails; with --force succeeds. + self.assertEqual(gen.main([str(item)]), 1) + self.assertEqual(gen.main([str(item), "--force"]), 0) + + +class RealTemplateTest(unittest.TestCase): + """Runs against the actual committed 000-template (acceptance criterion).""" + + def test_generates_fillable_log_from_repo_template(self): + template = Path(__file__).resolve().parents[1] / "000-template" + preflight = (template / "preflight.md").read_text(encoding="utf-8") + steps = (template / "steps.md").read_text(encoding="utf-8") + log = gen.render_execution_log(template.name, preflight, steps) + self.assertIn("# Execution Log: 000-template", log) + self.assertIn("## Preflight", log) + self.assertIn("## Steps", log) + self.assertIn("## Outcome", log) + self.assertIn("COMPLETION REQUIRED", log) + # The template ships two matched pairs per file; every checklist step + # becomes a checkbox, so the log must contain checkboxes. + self.assertGreaterEqual(log.count("- [ ]"), 8) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From f21d9958a1fcb8087ad440c9330544b63859a813 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:18:29 +0100 Subject: [PATCH 23/70] chore(cm): ignore python cache in .tools --- change-management/.tools/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 change-management/.tools/.gitignore diff --git a/change-management/.tools/.gitignore b/change-management/.tools/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/change-management/.tools/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc From 97713aff2bd6fd66263dcd6326ddfed7cf6a15d4 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:20:03 +0100 Subject: [PATCH 24/70] style(cm): drop em-dashes from change-management skill headings --- .pi/skills/change-management/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pi/skills/change-management/SKILL.md b/.pi/skills/change-management/SKILL.md index 186c8d3c..eec09959 100644 --- a/.pi/skills/change-management/SKILL.md +++ b/.pi/skills/change-management/SKILL.md @@ -103,7 +103,7 @@ Every item folder must contain `description.md`, `preflight.md`, and `steps.md`. The authoritative formats are the files in `change-management/000-template/`: read and copy those. Their roles and enforced shapes: -### `description.md` — rationale and risk assessment +### `description.md`: rationale and risk assessment A fixed section structure the validator enforces. It must contain these `##` sections, each with its `####` prompts (see `000-template/description.md` for @@ -121,11 +121,11 @@ the full prompt wording): Do not add, remove, or rename these headings. -### `preflight.md` — everything before execution +### `preflight.md`: everything before execution Merge, deploy, dry-run, backups: the work done before the change is executed. -### `steps.md` — the execution itself +### `steps.md`: the execution itself The change action plus any repo housekeeping that finalizes the change (committing the execution log, renaming the folder with a status suffix). From aa765dcb5d0edc25f655e16464170972c2cb53a2 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:20:51 +0100 Subject: [PATCH 25/70] feat(gateway): relocate TokenValidator to access package 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. --- services/gateway/internal/access/validator.go | 23 ++++++++++++++++ services/gateway/internal/middleware/auth.go | 26 ++++++++----------- 2 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 services/gateway/internal/access/validator.go diff --git a/services/gateway/internal/access/validator.go b/services/gateway/internal/access/validator.go new file mode 100644 index 00000000..587d2171 --- /dev/null +++ b/services/gateway/internal/access/validator.go @@ -0,0 +1,23 @@ +package access + +import "context" + +// TokenValidationResult holds the identity returned by the auth service after a +// successful token validation. Its canonical home is this package because it is +// the value consumed by AccessControl. +type TokenValidationResult struct { + UserID string + Role string + Username string + AssumedBy string +} + +// TokenValidator abstracts the gRPC call to the auth service's ValidateToken +// RPC. Its canonical home is this package because AccessControl consumes it. +// The concrete gRPC client is unchanged and satisfies this interface +// structurally. Defining the interface here (rather than in middleware) keeps +// the access model self-contained and lets AccessControl be unit-tested with a +// fake validator, no gRPC connection required. +type TokenValidator interface { + ValidateToken(ctx context.Context, accessToken string) (*TokenValidationResult, error) +} diff --git a/services/gateway/internal/middleware/auth.go b/services/gateway/internal/middleware/auth.go index cf00cad7..ea7f37bf 100644 --- a/services/gateway/internal/middleware/auth.go +++ b/services/gateway/internal/middleware/auth.go @@ -1,27 +1,23 @@ package middleware import ( - "context" "log/slog" "net/http" "github.com/gin-gonic/gin" -) -// TokenValidationResult holds the identity returned by the auth service -// after a successful token validation. -type TokenValidationResult struct { - UserID string - Role string - Username string - AssumedBy string -} + "github.com/ItsThompson/gofin/services/gateway/internal/access" +) -// TokenValidator abstracts the gRPC call to the auth service's ValidateToken RPC. -// This interface enables unit testing without a real gRPC connection. -type TokenValidator interface { - ValidateToken(ctx context.Context, accessToken string) (*TokenValidationResult, error) -} +// TokenValidationResult and TokenValidator have moved to the access package, +// their canonical home (they are consumed by access.AccessControl). These +// aliases keep middleware.Auth, the gRPC client, and existing callers +// compiling during the gateway access-control refactor; they are removed +// together with this file in the router-cutover ticket. +type ( + TokenValidationResult = access.TokenValidationResult + TokenValidator = access.TokenValidator +) // unauthenticatedRoute defines a method+path pair that bypasses auth validation. type unauthenticatedRoute struct { From f759866ef4ea9d4f66e76027c536fd673ab6b3e0 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:20:59 +0100 Subject: [PATCH 26/70] feat(shell): add direct-admin route guard and role-derived nav 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. --- .../apps/shell/app/routes/auth-layout.tsx | 82 ++++++++--- services/gateway/internal/access/control.go | 135 ++++++++++++++++++ 2 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 services/gateway/internal/access/control.go diff --git a/frontend/apps/shell/app/routes/auth-layout.tsx b/frontend/apps/shell/app/routes/auth-layout.tsx index 6087ad7a..a986e440 100644 --- a/frontend/apps/shell/app/routes/auth-layout.tsx +++ b/frontend/apps/shell/app/routes/auth-layout.tsx @@ -1,5 +1,6 @@ import { Outlet, NavLink, useNavigate, useLocation } from "react-router"; import { useAuthStore } from "@/stores/auth-store"; +import { canUseAdminFeatures, getLandingPath } from "@gofin/core"; import { useEffect, useState } from "react"; import { Button } from "@gofin/ui/components/button"; import { @@ -15,11 +16,23 @@ import { PlusCircle, } from "lucide-react"; +/** + * Personal-finance routes an operator (direct admin) must never land on. The + * direct-admin guard bounces these to /admin; the gateway 403 is the real + * enforcement, this guard is defense-in-depth to avoid a flash of finance UI. + */ +const FINANCE_ROUTES = [ + "/dashboard", + "/expenses", + "/expenses/new", + "/history", + "/onboarding", +]; + export default function AuthLayout() { const { user, isAuthenticated, - isAdmin, isAssuming, isLoading, checkAuth, @@ -40,19 +53,33 @@ export default function AuthLayout() { } }, [isLoading, isAuthenticated, navigate]); - // Onboarding redirect guards + // Redirect guards (precedence: direct-admin guard first, then onboarding). const location = useLocation(); const isOnOnboarding = location.pathname === "/onboarding"; useEffect(() => { if (isLoading || !isAuthenticated || !user) return; - if (!user.hasCompletedOnboarding && !isOnOnboarding) { + const path = location.pathname; + + // 1. Direct-admin guard (takes precedence): an operator is never routed to + // a finance route or /onboarding. Skipped while assuming a user. + if ( + canUseAdminFeatures(user) && + !isAssuming && + FINANCE_ROUTES.includes(path) + ) { + navigate("/admin"); + return; + } + + // 2. Onboarding guard (unchanged for users). + if (!user.hasCompletedOnboarding && path !== "/onboarding") { navigate("/onboarding"); - } else if (user.hasCompletedOnboarding && isOnOnboarding) { - navigate("/dashboard"); + } else if (user.hasCompletedOnboarding && path === "/onboarding") { + navigate(getLandingPath(user)); } - }, [isLoading, isAuthenticated, user, isOnOnboarding, navigate]); + }, [isLoading, isAuthenticated, user, isAssuming, location.pathname, navigate]); if (isLoading) { return ( @@ -62,12 +89,12 @@ export default function AuthLayout() { ); } - if (!isAuthenticated) { + if (!isAuthenticated || !user) { return null; } // Render onboarding page without the nav chrome - if (!user?.hasCompletedOnboarding && isOnOnboarding) { + if (!user.hasCompletedOnboarding && isOnOnboarding) { return <Outlet />; } @@ -86,16 +113,26 @@ export default function AuthLayout() { } }; - const navLinks = [ - { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, - { to: "/expenses", label: "Expenses", icon: Receipt }, - { to: "/history", label: "History", icon: History }, - { to: "/settings", label: "Settings", icon: Settings }, - ]; + // A direct admin (operator, not assuming) gets an operator-only navbar and no + // Log Expense FAB. A regular user or an assumed session keeps the finance nav. + const isDirectAdmin = canUseAdminFeatures(user) && !isAssuming; - if (isAdmin) { - navLinks.push({ to: "/admin", label: "Admin", icon: Shield }); - } + const navLinks = isDirectAdmin + ? [ + { to: "/admin", label: "Admin", icon: Shield }, + { to: "/settings", label: "Settings", icon: Settings }, + ] + : [ + { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { to: "/expenses", label: "Expenses", icon: Receipt }, + { to: "/history", label: "History", icon: History }, + { to: "/settings", label: "Settings", icon: Settings }, + ]; + + // FAB is hidden for a direct admin and, as today, while assuming (the Return + // to Admin control occupies the same corner) and on the new-expense page. + const showLogExpenseFab = + !isDirectAdmin && !isAssuming && location.pathname !== "/expenses/new"; return ( <div className="min-h-screen bg-background"> @@ -103,7 +140,10 @@ export default function AuthLayout() { <header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="mx-auto flex h-14 max-w-7xl items-center px-4"> {/* Logo */} - <NavLink to="/dashboard" className="mr-6 flex items-center gap-2"> + <NavLink + to={getLandingPath(user)} + className="mr-6 flex items-center gap-2" + > <span className="text-lg font-bold">GoFin</span> </NavLink> @@ -133,7 +173,7 @@ export default function AuthLayout() { {/* User menu (desktop) */} <div className="hidden items-center gap-3 md:flex"> <span className="text-sm text-muted-foreground"> - {user?.username} + {user.username} </span> <Button variant="ghost" size="sm" onClick={handleLogout}> <LogOut className="size-4" /> @@ -180,7 +220,7 @@ export default function AuthLayout() { ))} <div className="mt-2 border-t pt-2"> <div className="px-3 py-1 text-sm text-muted-foreground"> - {user?.username} + {user.username} </div> <button onClick={handleLogout} @@ -214,7 +254,7 @@ export default function AuthLayout() { {/* Mobile floating "Log Expense" FAB: visible on mobile, hidden when already on the new-expense page or when the admin return button is visible (to avoid overlap). */} - {!isAssuming && location.pathname !== "/expenses/new" && ( + {showLogExpenseFab && ( <div className="fixed bottom-6 right-6 z-40 md:hidden"> <NavLink to="/expenses/new"> <Button diff --git a/services/gateway/internal/access/control.go b/services/gateway/internal/access/control.go new file mode 100644 index 00000000..5b38c167 --- /dev/null +++ b/services/gateway/internal/access/control.go @@ -0,0 +1,135 @@ +package access + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Identity headers the gateway sets for downstream services after a successful +// validation. They are stripped from every inbound request first so a client +// can never spoof them; the gateway is their only legitimate source. +const ( + headerUserID = "X-User-ID" + headerUserRole = "X-User-Role" + headerAssumedBy = "X-Assumed-By" +) + +// accessCookie is the cookie carrying the access token validated on every +// non-public request. +const accessCookie = "gofin_access" + +// Role values returned by the auth service in TokenValidationResult.Role. An +// assumed session carries roleUser, so it satisfies Personal routes. +const ( + roleUser = "user" + roleAdmin = "admin" +) + +// AccessControl is the single gin middleware that enforces the gateway access +// policy, replacing the former Auth + RequireAdmin + AdminRouteGuard trio. +// +// For every request it: +// 1. strips the spoofable identity headers, +// 2. resolves the route's access level from the policy, +// 3. short-circuits Public routes with no token read, +// 4. otherwise validates the gofin_access cookie (401 on missing/invalid), +// 5. injects the validated identity as downstream headers, and +// 6. enforces the per-level role check (403 when the role is wrong). +func AccessControl(validator TokenValidator, policy Policy, logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + stripIdentityHeaders(c) + + level := policy.resolve(c.Request.Method, c.Request.URL.Path) + if level == Public { + c.Next() + return + } + + cookie, err := c.Request.Cookie(accessCookie) + if err != nil || cookie.Value == "" { + logger.Warn("missing access token cookie", + slog.String("method", c.Request.Method), + slog.String("path", c.Request.URL.Path), + ) + abortUnauthorized(c, "Authentication required") + return + } + + result, err := validator.ValidateToken(c.Request.Context(), cookie.Value) + if err != nil { + logger.Warn("token validation failed", + slog.String("method", c.Request.Method), + slog.String("path", c.Request.URL.Path), + slog.String("error", err.Error()), + ) + abortUnauthorized(c, "Invalid or expired token") + return + } + + setIdentityHeaders(c, result) + + switch level { + case Personal: + if result.Role != roleUser { + rejectForbidden(c, logger, result) + return + } + case Admin: + if result.Role != roleAdmin { + rejectForbidden(c, logger, result) + return + } + case Public, Authenticated: + // Public is short-circuited above; Authenticated needs only a valid + // token, which we now have. Neither enforces a role. + } + + c.Next() + } +} + +// stripIdentityHeaders removes client-supplied identity headers before +// resolution so they can never be spoofed. The gateway sets them only after a +// successful validation (see setIdentityHeaders). +func stripIdentityHeaders(c *gin.Context) { + c.Request.Header.Del(headerUserID) + c.Request.Header.Del(headerUserRole) + c.Request.Header.Del(headerAssumedBy) +} + +// setIdentityHeaders injects the validated identity for downstream services and +// stores the user id in the gin context for RequestLogger. X-Assumed-By is only +// forwarded when the session is assumed. +func setIdentityHeaders(c *gin.Context, result *TokenValidationResult) { + c.Request.Header.Set(headerUserID, result.UserID) + c.Request.Header.Set(headerUserRole, result.Role) + c.Set(headerUserID, result.UserID) + if result.AssumedBy != "" { + c.Request.Header.Set(headerAssumedBy, result.AssumedBy) + } +} + +// abortUnauthorized ends the request with the unchanged 401 contract. +func abortUnauthorized(c *gin.Context, message string) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "code": "UNAUTHORIZED", + "message": message, + }) +} + +// rejectForbidden ends the request with the unchanged 403 contract, preserving +// the role-denied warn log formerly emitted by middleware.rejectNonAdmin. +func rejectForbidden(c *gin.Context, logger *slog.Logger, result *TokenValidationResult) { + logger.Warn("access denied", + slog.String("method", c.Request.Method), + slog.String("path", c.Request.URL.Path), + slog.String("role", result.Role), + slog.String("user_id", result.UserID), + ) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "code": "FORBIDDEN", + "message": "Access denied", + }) +} From b16473dccfb3a763629a003de35d9cfd1f75cd2b Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:21:11 +0100 Subject: [PATCH 27/70] test(shell): cover admin guard precedence, operator nav, and assumed 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). --- .../shell/app/__tests__/auth-layout.test.tsx | 170 +++++++++++++++++- 1 file changed, 168 insertions(+), 2 deletions(-) diff --git a/frontend/apps/shell/app/__tests__/auth-layout.test.tsx b/frontend/apps/shell/app/__tests__/auth-layout.test.tsx index b8c5b3c8..15874934 100644 --- a/frontend/apps/shell/app/__tests__/auth-layout.test.tsx +++ b/frontend/apps/shell/app/__tests__/auth-layout.test.tsx @@ -165,13 +165,18 @@ describe("AuthLayout - mobile menu and actions", () => { }); }); - it("shows admin nav link when user is admin", async () => { + it("redirects a direct admin off a finance route to /admin", async () => { resetStore({ isLoading: false, isAuthenticated: true, isAdmin: true, user: adminUser, }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ user: adminUser }), + }); const AuthLayout = await importAuthLayout(); const router = createMemoryRouter( @@ -181,13 +186,174 @@ describe("AuthLayout - mobile menu and actions", () => { element: <AuthLayout />, children: [{ index: true, element: <div>Dashboard content</div> }], }, + { path: "/admin", element: <div>Admin page</div> }, { path: "/login", element: <div>Login redirect target</div> }, ], { initialEntries: ["/dashboard"] }, ); render(<RouterProvider router={router} />); - expect(screen.getByText("Admin")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Admin page")).toBeInTheDocument(); + }); + expect(screen.queryByText("Dashboard content")).not.toBeInTheDocument(); + }); + + it("runs the admin guard before the onboarding guard", async () => { + // An operator that is somehow not onboarded still lands on /admin, never /onboarding. + const unonboardedAdmin = { ...adminUser, hasCompletedOnboarding: false }; + resetStore({ + isLoading: false, + isAuthenticated: true, + isAdmin: true, + user: unonboardedAdmin, + }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ user: unonboardedAdmin }), + }); + const AuthLayout = await importAuthLayout(); + + const router = createMemoryRouter( + [ + { + path: "/dashboard", + element: <AuthLayout />, + children: [{ index: true, element: <div>Dashboard content</div> }], + }, + { path: "/admin", element: <div>Admin page</div> }, + { + path: "/onboarding", + element: <AuthLayout />, + children: [{ index: true, element: <div>Onboarding page</div> }], + }, + { path: "/login", element: <div>Login redirect target</div> }, + ], + { initialEntries: ["/dashboard"] }, + ); + render(<RouterProvider router={router} />); + + await waitFor(() => { + expect(screen.getByText("Admin page")).toBeInTheDocument(); + }); + expect(screen.queryByText("Onboarding page")).not.toBeInTheDocument(); + }); + + it("does not run the admin guard while assuming a user", async () => { + // Even when the stored user is an admin, an assumed session must not redirect. + resetStore({ + isLoading: false, + isAuthenticated: true, + isAdmin: true, + isAssuming: true, + user: adminUser, + }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ user: adminUser }), + }); + const AuthLayout = await importAuthLayout(); + + const router = createMemoryRouter( + [ + { + path: "/dashboard", + element: <AuthLayout />, + children: [{ index: true, element: <div>Dashboard content</div> }], + }, + { path: "/admin", element: <div>Admin page</div> }, + { path: "/login", element: <div>Login redirect target</div> }, + ], + { initialEntries: ["/dashboard"] }, + ); + render(<RouterProvider router={router} />); + + // Stays on the finance route; Return to Admin is shown instead of a redirect. + expect(await screen.findByText("Dashboard content")).toBeInTheDocument(); + expect(screen.getByText("Return to Admin")).toBeInTheDocument(); + expect(screen.queryByText("Admin page")).not.toBeInTheDocument(); + }); + + it("shows only Admin and Settings nav for a direct admin (no finance links, no FAB)", async () => { + resetStore({ + isLoading: false, + isAuthenticated: true, + isAdmin: true, + user: adminUser, + }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ user: adminUser }), + }); + const AuthLayout = await importAuthLayout(); + + // Mount on a non-finance route so the direct-admin guard does not redirect. + const router = createMemoryRouter( + [ + { + path: "/settings", + element: <AuthLayout />, + children: [{ index: true, element: <div>Settings content</div> }], + }, + { path: "/admin", element: <div>Admin page</div> }, + { path: "/login", element: <div>Login redirect target</div> }, + ], + { initialEntries: ["/settings"] }, + ); + render(<RouterProvider router={router} />); + + expect(await screen.findByText("Admin")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + expect(screen.queryByText("Dashboard")).not.toBeInTheDocument(); + expect(screen.queryByText("Expenses")).not.toBeInTheDocument(); + expect(screen.queryByText("History")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Log Expense")).not.toBeInTheDocument(); + + // Logo targets getLandingPath(admin) === /admin. + expect(screen.getByText("GoFin").closest("a")).toHaveAttribute( + "href", + "/admin", + ); + }); + + it("keeps full user nav plus Return to Admin for an assumed session", async () => { + resetStore({ + isLoading: false, + isAuthenticated: true, + isAssuming: true, + user: authenticatedUser, + }); + const AuthLayout = await importAuthLayout(); + + const router = createMemoryRouter( + [ + { + path: "/dashboard", + element: <AuthLayout />, + children: [{ index: true, element: <div>Dashboard content</div> }], + }, + { path: "/admin", element: <div>Admin page</div> }, + { path: "/login", element: <div>Login redirect target</div> }, + ], + { initialEntries: ["/dashboard"] }, + ); + render(<RouterProvider router={router} />); + + expect(await screen.findByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("Expenses")).toBeInTheDocument(); + expect(screen.getByText("History")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + expect(screen.getByText("Return to Admin")).toBeInTheDocument(); + // No operator Admin link while acting as a regular user. + expect(screen.queryByText("Admin")).not.toBeInTheDocument(); + // Logo targets getLandingPath(user) === /dashboard. + expect(screen.getByText("GoFin").closest("a")).toHaveAttribute( + "href", + "/dashboard", + ); }); it("shows Return to Admin button when assuming identity", async () => { From 216075d6a17d393339a24cc67af95d98f63b2fc5 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:22:33 +0100 Subject: [PATCH 28/70] test(gateway): add AccessControl matrix and behavior tests 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. --- .../gateway/internal/access/control_test.go | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 services/gateway/internal/access/control_test.go diff --git a/services/gateway/internal/access/control_test.go b/services/gateway/internal/access/control_test.go new file mode 100644 index 00000000..44d12ccc --- /dev/null +++ b/services/gateway/internal/access/control_test.go @@ -0,0 +1,388 @@ +package access_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/gateway/internal/access" +) + +func init() { + // Silence gin's debug output during tests. + gin.SetMode(gin.TestMode) +} + +// fakeValidator is a TokenValidator that never touches gRPC. It records how +// many times it was called so tests can assert Public routes skip validation. +type fakeValidator struct { + result *access.TokenValidationResult + err error + calls int +} + +func (f *fakeValidator) ValidateToken(_ context.Context, _ string) (*access.TokenValidationResult, error) { + f.calls++ + return f.result, f.err +} + +func silentLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// captureLogger returns a JSON logger and the buffer it writes to, so tests can +// assert on the structured fields emitted on 401/403. +func captureLogger() (*slog.Logger, *bytesBuffer) { + buf := &bytesBuffer{} + logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelWarn})) + return logger, buf +} + +// buildEngine wires AccessControl (with the canonical DefaultPolicy) in front +// of a single handler registered for method+path. +func buildEngine(validator access.TokenValidator, logger *slog.Logger, method, path string, handler gin.HandlerFunc) *gin.Engine { + engine := gin.New() + engine.Use(access.AccessControl(validator, access.DefaultPolicy(), logger)) + engine.Handle(method, path, handler) + return engine +} + +func okHandler(c *gin.Context) { c.Status(http.StatusOK) } + +// --- Matrix: {Public, Authenticated, Personal, Admin} x {no-token, user, admin, assumed-user} --- + +type tokenScenario struct { + name string + cookie bool + newValidator func() *fakeValidator +} + +func tokenScenarios() []tokenScenario { + return []tokenScenario{ + { + name: "no-token", + cookie: false, + // Errors if reached; a cookie-less request must 401 before validation. + newValidator: func() *fakeValidator { + return &fakeValidator{err: errors.New("validate should not be called without a cookie")} + }, + }, + { + name: "user", + cookie: true, + newValidator: func() *fakeValidator { + return &fakeValidator{result: &access.TokenValidationResult{UserID: "user-1", Role: "user"}} + }, + }, + { + name: "admin", + cookie: true, + newValidator: func() *fakeValidator { + return &fakeValidator{result: &access.TokenValidationResult{UserID: "admin-1", Role: "admin"}} + }, + }, + { + name: "assumed-user", + cookie: true, + newValidator: func() *fakeValidator { + return &fakeValidator{result: &access.TokenValidationResult{UserID: "target-1", Role: "user", AssumedBy: "admin-1"}} + }, + }, + } +} + +func TestAccessControl_Matrix(t *testing.T) { + routes := []struct { + level string + method string + path string + }{ + {"Public", http.MethodPost, "/api/auth/login"}, + {"Authenticated", http.MethodPost, "/api/auth/restore"}, + {"Personal", http.MethodGet, "/api/finance/periods"}, + {"Admin", http.MethodGet, "/api/admin/users"}, + } + + // Expected status per (access level x token scenario). + want := map[string]map[string]int{ + "Public": {"no-token": 200, "user": 200, "admin": 200, "assumed-user": 200}, + "Authenticated": {"no-token": 401, "user": 200, "admin": 200, "assumed-user": 200}, + "Personal": {"no-token": 401, "user": 200, "admin": 403, "assumed-user": 200}, + "Admin": {"no-token": 401, "user": 403, "admin": 200, "assumed-user": 403}, + } + + for _, route := range routes { + for _, sc := range tokenScenarios() { + t.Run(route.level+"/"+sc.name, func(t *testing.T) { + validator := sc.newValidator() + engine := buildEngine(validator, silentLogger(), route.method, route.path, okHandler) + + req := httptest.NewRequest(route.method, route.path, nil) + if sc.cookie { + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + } + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, want[route.level][sc.name], rec.Code, + "%s route with %s token", route.level, sc.name) + }) + } + } +} + +// --- Public routes: no cookie read, no validation --- + +func TestAccessControl_Public_SkipsValidationAndStripsHeaders(t *testing.T) { + validator := &fakeValidator{ + result: &access.TokenValidationResult{UserID: "should-not-appear", Role: "admin"}, + } + + var userID, role, assumedBy string + engine := buildEngine(validator, silentLogger(), http.MethodPost, "/api/auth/login", func(c *gin.Context) { + userID = c.Request.Header.Get("X-User-ID") + role = c.Request.Header.Get("X-User-Role") + assumedBy = c.Request.Header.Get("X-Assumed-By") + c.Status(http.StatusOK) + }) + + // A cookie is present and identity headers are spoofed: neither should matter. + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + req.Header.Set("X-User-ID", "spoofed-user") + req.Header.Set("X-User-Role", "admin") + req.Header.Set("X-Assumed-By", "spoofed-admin") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, 0, validator.calls, "Public routes must not call ValidateToken") + assert.Empty(t, userID, "spoofed X-User-ID must be stripped on Public routes") + assert.Empty(t, role, "spoofed X-User-Role must be stripped on Public routes") + assert.Empty(t, assumedBy, "spoofed X-Assumed-By must be stripped on Public routes") +} + +// --- Anti-spoof: inbound identity headers replaced by validated identity --- + +func TestAccessControl_StripsSpoofedHeaders_UsesValidatedIdentity(t *testing.T) { + validator := &fakeValidator{ + result: &access.TokenValidationResult{UserID: "user-1", Role: "user"}, + } + + var userID, role, assumedBy string + engine := buildEngine(validator, silentLogger(), http.MethodGet, "/api/finance/periods", func(c *gin.Context) { + userID = c.Request.Header.Get("X-User-ID") + role = c.Request.Header.Get("X-User-Role") + assumedBy = c.Request.Header.Get("X-Assumed-By") + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/finance/periods", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + req.Header.Set("X-User-ID", "spoofed-admin") + req.Header.Set("X-User-Role", "admin") + req.Header.Set("X-Assumed-By", "spoofed-admin") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "user-1", userID, "must forward validated user id, not spoofed") + assert.Equal(t, "user", role, "must forward validated role, not spoofed admin") + assert.Empty(t, assumedBy, "X-Assumed-By must be cleared when the token is not assumed") +} + +// --- On pass: downstream headers + gin context for RequestLogger --- + +func TestAccessControl_Pass_SetsDownstreamIdentityAndContext(t *testing.T) { + validator := &fakeValidator{ + result: &access.TokenValidationResult{UserID: "user-1", Role: "user"}, + } + + var headerUserID, contextUserID string + engine := buildEngine(validator, silentLogger(), http.MethodPost, "/api/auth/restore", func(c *gin.Context) { + headerUserID = c.Request.Header.Get("X-User-ID") + if v, ok := c.Get("X-User-ID"); ok { + contextUserID, _ = v.(string) + } + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/api/auth/restore", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "user-1", headerUserID, "X-User-ID header set downstream") + assert.Equal(t, "user-1", contextUserID, "X-User-ID set in gin context for RequestLogger") +} + +// --- Assumed session: X-Assumed-By forwarded, reaches Personal + restore --- + +func TestAccessControl_ForwardsAssumedBy(t *testing.T) { + validator := &fakeValidator{ + result: &access.TokenValidationResult{UserID: "target-1", Role: "user", AssumedBy: "admin-1"}, + } + + var assumedBy string + engine := buildEngine(validator, silentLogger(), http.MethodGet, "/api/finance/periods", func(c *gin.Context) { + assumedBy = c.Request.Header.Get("X-Assumed-By") + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/finance/periods", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "admin-1", assumedBy, "X-Assumed-By forwarded for an assumed session") +} + +func TestAccessControl_AssumedUser_PassesPersonalRoutesAndRestore(t *testing.T) { + routes := []struct { + method string + path string + }{ + {http.MethodPost, "/api/auth/onboarding-complete"}, // Personal (exact) + {http.MethodGet, "/api/finance/periods"}, // Personal (prefix) + {http.MethodPost, "/api/expenses"}, // Personal (prefix) + {http.MethodPost, "/api/datarights/exports"}, // Personal (prefix) + {http.MethodPost, "/api/auth/restore"}, // Authenticated + } + + for _, route := range routes { + t.Run(route.method+" "+route.path, func(t *testing.T) { + validator := &fakeValidator{ + result: &access.TokenValidationResult{UserID: "target-1", Role: "user", AssumedBy: "admin-1"}, + } + engine := buildEngine(validator, silentLogger(), route.method, route.path, okHandler) + + req := httptest.NewRequest(route.method, route.path, nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + }) + } +} + +// --- 401 paths --- + +func TestAccessControl_EmptyCookie_Returns401(t *testing.T) { + validator := &fakeValidator{err: errors.New("should not be called for an empty cookie")} + engine := buildEngine(validator, silentLogger(), http.MethodGet, "/api/auth/me", okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: ""}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "UNAUTHORIZED") + assert.Equal(t, 0, validator.calls) +} + +func TestAccessControl_ValidationError_Returns401(t *testing.T) { + validator := &fakeValidator{err: errors.New("token expired")} + engine := buildEngine(validator, silentLogger(), http.MethodGet, "/api/auth/me", okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "expired-token"}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "UNAUTHORIZED") +} + +// --- Warn logging on 401 and 403 --- + +func TestAccessControl_Unauthorized_LogsWarning(t *testing.T) { + logger, buf := captureLogger() + validator := &fakeValidator{err: errors.New("token expired")} + engine := buildEngine(validator, logger, http.MethodGet, "/api/finance/periods", okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/finance/periods", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "expired-token"}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + entry := buf.lastEntry(t) + assert.Equal(t, "WARN", entry["level"]) + assert.Equal(t, "GET", entry["method"]) + assert.Equal(t, "/api/finance/periods", entry["path"]) + assert.Equal(t, "token expired", entry["error"]) +} + +func TestAccessControl_Forbidden_LogsWarning(t *testing.T) { + logger, buf := captureLogger() + validator := &fakeValidator{ + result: &access.TokenValidationResult{UserID: "user-1", Role: "user"}, + } + engine := buildEngine(validator, logger, http.MethodGet, "/api/admin/users", okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + require.Equal(t, http.StatusForbidden, rec.Code) + assert.Contains(t, rec.Body.String(), "FORBIDDEN") + + entry := buf.lastEntry(t) + assert.Equal(t, "WARN", entry["level"]) + assert.Equal(t, "GET", entry["method"]) + assert.Equal(t, "/api/admin/users", entry["path"]) + assert.Equal(t, "user", entry["role"]) + assert.Equal(t, "user-1", entry["user_id"]) +} + +// bytesBuffer is a minimal io.Writer that also parses the last JSON log line. +type bytesBuffer struct { + data []byte +} + +func (b *bytesBuffer) Write(p []byte) (int, error) { + b.data = append(b.data, p...) + return len(p), nil +} + +// lastEntry parses the final newline-delimited JSON log line into a map. +func (b *bytesBuffer) lastEntry(t *testing.T) map[string]any { + t.Helper() + lines := splitNonEmptyLines(b.data) + require.NotEmpty(t, lines, "expected at least one log line") + + var entry map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[len(lines)-1]), &entry)) + return entry +} + +func splitNonEmptyLines(data []byte) []string { + var lines []string + start := 0 + for i, b := range data { + if b == '\n' { + if i > start { + lines = append(lines, string(data[start:i])) + } + start = i + 1 + } + } + if start < len(data) { + lines = append(lines, string(data[start:])) + } + return lines +} From 5277aa3000775332cbf317a9e442946235498de3 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:23:37 +0100 Subject: [PATCH 29/70] feat(cm): add change-management validator - 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 --- .../.validation/validate_change_management.py | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100755 change-management/.validation/validate_change_management.py diff --git a/change-management/.validation/validate_change_management.py b/change-management/.validation/validate_change_management.py new file mode 100755 index 00000000..7297627a --- /dev/null +++ b/change-management/.validation/validate_change_management.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Validate change-management item folders against the framework rules (see +`change-management/` section 6 of the Admin Operator Refactor spec). + +Runs in CI and on a developer machine (never on the prod VPS). Validates a +single item (``--item <folder>``) or every item under the root (default). + +For each item the following checks run, in order, and the FIRST violation for +that item is reported (item, file, failing rule): + + 1. Folder name - pre-completion regex, or completed regex with a valid + status suffix; ``000-template`` is the reserved literal. + 2. Required files - ``description.md``, ``preflight.md``, ``steps.md``. + 3. Description - all required ``##``/``####`` headings are present. + 4. Activity/Val. - ``preflight.md`` and ``steps.md`` pair every + ``# Activity N`` with a ``# Validation N``; each block + carries a ``**Description**`` line, ``## Checklist:``, + and ``## Rollback Plan:``. + 5. Completion - a folder with a status suffix requires a non-empty + ``execution-log.md``; a folder without one does not. + +``assets/`` contents are never validated (arbitrary supporting files). + +Exit codes: + 0 every validated item conforms + 1 at least one item has a violation (each is printed to stderr) + 2 usage error (root or item path does not exist) +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +TEMPLATE_NAME = "000-template" +REQUIRED_FILES = ("description.md", "preflight.md", "steps.md") +EXECUTION_LOG = "execution-log.md" +STATUS_SUFFIXES = ("completed-off-script", "completed", "failed", "aborted") + +# Folder naming (section 6). Titles are lowercase kebab-case and never contain +# an underscore, so the underscore before an optional status suffix is +# unambiguous. +_TITLE = r"[a-z0-9]+(?:-[a-z0-9]+)*" +_STATUS = "|".join(STATUS_SUFFIXES) +PRE_COMPLETION_RE = re.compile(rf"^\d{{3}}_{_TITLE}$") +COMPLETED_RE = re.compile(rf"^\d{{3}}_{_TITLE}_(?:{_STATUS})$") + +# The fixed heading structure the `description.md` must carry (level, text). +REQUIRED_DESCRIPTION_HEADINGS: tuple[tuple[int, str], ...] = ( + (2, "Change Event"), + (4, "What is the purpose of this activity or change?"), + (4, "What will be required to execute this change?"), + (4, "What is the expected end state of the system after this change?"), + (4, "What assumptions, if any, are being made about the state of the " + "system at the time of this change?"), + (4, "Rollout Date/Time(s) and Duration"), + (2, "Impact / Risk Assessment"), + (4, "Why is it necessary? What is the impact of not making this change?"), + (4, "Why does this activity or change need to be done under Change " + "Management? Can it be safely automated?"), + (4, "Are there any related, prerequisite changes upon which this CM " + "hinges?"), + (4, "Will this CM be in any way intrusive, and if so, how will you know? " + "What teams, services or functionality will be impacted?"), + (4, "How has this change been tested to verify it's safe for production?"), + (2, "Worst Case Scenario"), + (4, "What could happen if everything goes wrong with this change?"), + (4, "How does this CM attempt to mitigate this risk?"), + (2, "Rollback Procedure"), + (4, "What conditions would indicate a need to rollback?"), + (4, "In the event of problems, what will you do to return your system to " + "a known good state?"), + (4, "If this is a software or infrastructure change, has the rollback " + "procedure been verified in a development environment?"), +) + +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") +_FENCE_RE = re.compile(r"^\s*(```|~~~)") +_AV_HEADER_RE = re.compile(r"^#\s+(Activity|Validation)\s+(\d+)\b") +_DESCRIPTION_LINE_RE = re.compile(r"^\*\*Description\*\*") +_CHECKLIST_RE = re.compile(r"^##\s+Checklist:") +_ROLLBACK_RE = re.compile(r"^##\s+Rollback Plan:") + + +@dataclass(frozen=True) +class Violation: + """A single conformance failure for one item.""" + + item: str + file: str # "" for a folder-level (naming) violation + rule: str + + def __str__(self) -> str: + where = f"{self.item}/{self.file}" if self.file else self.item + return f"FAIL [{where}]: {self.rule}" + + +@dataclass +class Block: + """One `# Activity N` / `# Validation N` block and its body lines.""" + + kind: str + index: int + lines: list[str] + + +def is_status_suffixed(name: str) -> bool: + """True when the folder name carries a valid completion status suffix.""" + return name != TEMPLATE_NAME and bool(COMPLETED_RE.match(name)) + + +def check_folder_name(name: str) -> str | None: + if name == TEMPLATE_NAME: + return None + if PRE_COMPLETION_RE.match(name) or COMPLETED_RE.match(name): + return None + return ( + f"folder name does not match '<NNN>_<kebab-title>' or " + f"'<NNN>_<kebab-title>_<status>' " + f"(status one of: {', '.join(sorted(STATUS_SUFFIXES))})" + ) + + +def _parse_headings(text: str) -> set[tuple[int, str]]: + """Return the set of (level, text) ATX headings, ignoring fenced code.""" + headings: set[tuple[int, str]] = set() + in_fence = False + for line in text.splitlines(): + if _FENCE_RE.match(line): + in_fence = not in_fence + continue + if in_fence: + continue + match = _HEADING_RE.match(line) + if match: + headings.add((len(match.group(1)), match.group(2).strip())) + return headings + + +def check_description_headings(text: str) -> str | None: + present = _parse_headings(text) + for level, title in REQUIRED_DESCRIPTION_HEADINGS: + if (level, title) not in present: + return f"missing required heading: {'#' * level} {title}" + return None + + +def _parse_av_blocks(text: str) -> list[Block]: + """Split the file into `# Activity N` / `# Validation N` blocks.""" + blocks: list[Block] = [] + current: Block | None = None + in_fence = False + for line in text.splitlines(): + if _FENCE_RE.match(line): + in_fence = not in_fence + if current is not None: + current.lines.append(line) + continue + if not in_fence: + header = _AV_HEADER_RE.match(line) + if header: + current = Block(header.group(1), int(header.group(2)), []) + blocks.append(current) + continue + if current is not None: + current.lines.append(line) + return blocks + + +def _check_block_sections(block: Block) -> str | None: + body = block.lines + if not any(_DESCRIPTION_LINE_RE.match(line) for line in body): + return "missing '**Description**' line" + if not any(_CHECKLIST_RE.match(line) for line in body): + return "missing '## Checklist:' section" + if not any(_ROLLBACK_RE.match(line) for line in body): + return "missing '## Rollback Plan:' section" + return None + + +def check_activity_validation(text: str) -> str | None: + blocks = _parse_av_blocks(text) + if not blocks: + return "no '# Activity N' / '# Validation N' blocks found" + + activities: set[int] = set() + validations: set[int] = set() + for block in blocks: + section_error = _check_block_sections(block) + if section_error: + return f"{block.kind} {block.index}: {section_error}" + target = activities if block.kind == "Activity" else validations + target.add(block.index) + + if not activities: + return "no '# Activity N' block found" + for index in sorted(activities): + if index not in validations: + return f"Activity {index} has no matching Validation {index}" + for index in sorted(validations): + if index not in activities: + return f"Validation {index} has no matching Activity {index}" + return None + + +def check_completion_evidence(item_path: Path) -> str | None: + log = item_path / EXECUTION_LOG + if not log.is_file(): + return ( + "folder has a status suffix but is missing execution-log.md " + "(completion evidence is required)" + ) + if not log.read_text(encoding="utf-8").strip(): + return "execution-log.md is present but empty" + return None + + +def validate_item(item_path: Path) -> Violation | None: + """Validate one item folder; return the first violation, or None.""" + name = item_path.name + + name_error = check_folder_name(name) + if name_error: + return Violation(name, "", name_error) + + for required in REQUIRED_FILES: + if not (item_path / required).is_file(): + return Violation(name, required, "required file is missing") + + description = (item_path / "description.md").read_text(encoding="utf-8") + description_error = check_description_headings(description) + if description_error: + return Violation(name, "description.md", description_error) + + for md_file in ("preflight.md", "steps.md"): + text = (item_path / md_file).read_text(encoding="utf-8") + av_error = check_activity_validation(text) + if av_error: + return Violation(name, md_file, av_error) + + if is_status_suffixed(name): + completion_error = check_completion_evidence(item_path) + if completion_error: + return Violation(name, EXECUTION_LOG, completion_error) + + return None + + +def find_items(root: Path) -> list[Path]: + """All item folders under root (dot-prefixed dirs like .validation and + .tools are ignored, as are regular files).""" + return [ + entry + for entry in sorted(root.iterdir()) + if entry.is_dir() and not entry.name.startswith(".") + ] + + +def _default_root() -> Path: + # The script lives at change-management/.validation/<this file>. + return Path(__file__).resolve().parent.parent + + +def _resolve_root(root_arg: str | None) -> Path: + return Path(root_arg) if root_arg else _default_root() + + +def _resolve_item(root: Path, item_arg: str) -> Path | None: + candidate = Path(item_arg) + if candidate.is_dir(): + return candidate + joined = root / item_arg + return joined if joined.is_dir() else None + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Validate change-management item folders.", + ) + parser.add_argument( + "--item", + help="validate a single item folder (name under --root, or a path)", + ) + parser.add_argument( + "--root", + help="root directory holding the items (default: the " + "change-management/ directory containing this script)", + ) + args = parser.parse_args(argv) + + root = _resolve_root(args.root) + if not root.is_dir(): + print(f"error: root '{root}' is not a directory", file=sys.stderr) + return 2 + + if args.item: + item_path = _resolve_item(root, args.item) + if item_path is None: + print(f"error: item '{args.item}' not found under {root}", + file=sys.stderr) + return 2 + items = [item_path] + else: + items = find_items(root) + + violations = [v for v in (validate_item(item) for item in items) if v] + + if violations: + for violation in violations: + print(violation, file=sys.stderr) + return 1 + + print(f"OK: {len(items)} change-management item(s) conform.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 1fe1b1e0ffc399057faf33563632e8ca931154df Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:23:42 +0100 Subject: [PATCH 30/70] test(cm): cover the change-management validator - 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 --- .../test_validate_change_management.py | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 change-management/.validation/test_validate_change_management.py diff --git a/change-management/.validation/test_validate_change_management.py b/change-management/.validation/test_validate_change_management.py new file mode 100644 index 00000000..5aab9af8 --- /dev/null +++ b/change-management/.validation/test_validate_change_management.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Tests for validate_change_management.py. + +Fixtures are built in a temp directory at runtime so no malformed items are +committed to the repo. Run from this directory: + + python3 -m unittest test_validate_change_management -v + +or via the repo root: + + python3 change-management/.validation/test_validate_change_management.py +""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + +_MODULE_PATH = Path(__file__).resolve().parent / "validate_change_management.py" +_spec = importlib.util.spec_from_file_location("validate_cm", _MODULE_PATH) +assert _spec and _spec.loader +cm = importlib.util.module_from_spec(_spec) +# Register before exec so dataclass decorators can resolve __module__. +sys.modules[_spec.name] = cm +_spec.loader.exec_module(cm) + +# The canonical template that ships in the repo; used to prove the validator +# accepts the real reserved template. +_TEMPLATE_DIR = _MODULE_PATH.parent.parent / "000-template" + + +def _description_md() -> str: + lines = ["# Description: 001_example", ""] + for level, title in cm.REQUIRED_DESCRIPTION_HEADINGS: + lines.append(f"{'#' * level} {title}") + lines.append("") + lines.append("<content>") + lines.append("") + return "\n".join(lines) + + +def _av_block(kind: str, index: int) -> str: + return "\n".join( + [ + f"# {kind} {index}: title", + "", + "**Description**: what this does", + "", + "## Checklist:", + "1. do a thing", + "", + "## Rollback Plan:", + "1. undo the thing", + "", + ] + ) + + +def _av_md(pairs: int = 2) -> str: + blocks = [] + for index in range(1, pairs + 1): + blocks.append(_av_block("Activity", index)) + blocks.append(_av_block("Validation", index)) + return "\n".join(blocks) + + +def _write_valid_item(root: Path, name: str) -> Path: + item = root / name + (item / "assets").mkdir(parents=True) + (item / "description.md").write_text(_description_md(), encoding="utf-8") + (item / "preflight.md").write_text(_av_md(), encoding="utf-8") + (item / "steps.md").write_text(_av_md(), encoding="utf-8") + return item + + +class ValidateChangeManagementTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _rule(self, name: str) -> str | None: + violation = cm.validate_item(self.root / name) + return violation.rule if violation else None + + # --- conforming items ------------------------------------------------- + + def test_valid_precompletion_item_passes(self) -> None: + _write_valid_item(self.root, "001_example-item") + self.assertIsNone(cm.validate_item(self.root / "001_example-item")) + + def test_shipped_template_passes(self) -> None: + self.assertIsNone(cm.validate_item(_TEMPLATE_DIR)) + + def test_template_name_accepted(self) -> None: + _write_valid_item(self.root, cm.TEMPLATE_NAME) + self.assertIsNone(cm.validate_item(self.root / cm.TEMPLATE_NAME)) + + def test_completed_item_with_log_passes(self) -> None: + item = _write_valid_item(self.root, "001_example_completed") + (item / "execution-log.md").write_text("done", encoding="utf-8") + self.assertIsNone(cm.validate_item(item)) + + def test_all_status_suffixes_accepted(self) -> None: + for status in cm.STATUS_SUFFIXES: + item = _write_valid_item(self.root, f"00{1}_x_{status}") + (item / "execution-log.md").write_text("log", encoding="utf-8") + self.assertIsNone(cm.validate_item(item), status) + + # --- folder-name violations ------------------------------------------ + + def test_bad_padding_fails(self) -> None: + _write_valid_item(self.root, "1_example") + self.assertIn("folder name", self._rule("1_example") or "") + + def test_uppercase_title_fails(self) -> None: + _write_valid_item(self.root, "001_Example") + self.assertIn("folder name", self._rule("001_Example") or "") + + def test_invalid_status_suffix_fails(self) -> None: + _write_valid_item(self.root, "001_example_done") + self.assertIn("folder name", self._rule("001_example_done") or "") + + # --- required-file violations ---------------------------------------- + + def test_missing_required_file_fails(self) -> None: + item = _write_valid_item(self.root, "001_example") + (item / "steps.md").unlink() + rule = self._rule("001_example") + self.assertIn("required file is missing", rule or "") + + # --- description-heading violations ---------------------------------- + + def test_missing_heading_fails(self) -> None: + item = _write_valid_item(self.root, "001_example") + full = _description_md() + broken = full.replace("#### What could happen if everything goes " + "wrong with this change?", "#### Oops wrong") + (item / "description.md").write_text(broken, encoding="utf-8") + self.assertIn("missing required heading", self._rule("001_example") or "") + + # --- activity/validation violations ---------------------------------- + + def test_unmatched_activity_fails(self) -> None: + item = _write_valid_item(self.root, "001_example") + text = _av_block("Activity", 1) + _av_block("Validation", 1) \ + + _av_block("Activity", 2) + (item / "preflight.md").write_text(text, encoding="utf-8") + self.assertIn("Activity 2 has no matching Validation 2", + self._rule("001_example") or "") + + def test_block_missing_checklist_fails(self) -> None: + item = _write_valid_item(self.root, "001_example") + text = _av_block("Activity", 1).replace("## Checklist:", "## Tasks:") \ + + _av_block("Validation", 1) + (item / "steps.md").write_text(text, encoding="utf-8") + self.assertIn("Checklist", self._rule("001_example") or "") + + def test_block_missing_description_fails(self) -> None: + item = _write_valid_item(self.root, "001_example") + text = _av_block("Activity", 1).replace("**Description**:", + "Description:") \ + + _av_block("Validation", 1) + (item / "preflight.md").write_text(text, encoding="utf-8") + self.assertIn("Description", self._rule("001_example") or "") + + def test_empty_av_file_fails(self) -> None: + item = _write_valid_item(self.root, "001_example") + (item / "steps.md").write_text("# Steps\n\njust prose\n", + encoding="utf-8") + self.assertIn("no '# Activity N'", self._rule("001_example") or "") + + # --- completion-evidence violations ---------------------------------- + + def test_completed_without_log_fails(self) -> None: + _write_valid_item(self.root, "001_example_completed") + rule = self._rule("001_example_completed") or "" + self.assertIn("execution-log.md", rule) + + def test_completed_with_empty_log_fails(self) -> None: + item = _write_valid_item(self.root, "001_example_completed") + (item / "execution-log.md").write_text(" \n", encoding="utf-8") + self.assertIn("empty", self._rule("001_example_completed") or "") + + def test_precompletion_without_log_passes(self) -> None: + _write_valid_item(self.root, "001_example") + self.assertIsNone(cm.validate_item(self.root / "001_example")) + + # --- assets are not validated ---------------------------------------- + + def test_assets_contents_not_validated(self) -> None: + item = _write_valid_item(self.root, "001_example") + (item / "assets" / "junk.sql").write_text("### not a heading", + encoding="utf-8") + (item / "assets" / "notes.md").write_text("# Activity 9\nbroken", + encoding="utf-8") + self.assertIsNone(cm.validate_item(item)) + + # --- discovery / runner ---------------------------------------------- + + def test_find_items_ignores_dot_dirs_and_files(self) -> None: + _write_valid_item(self.root, "001_example") + (self.root / ".validation").mkdir() + (self.root / ".tools").mkdir() + (self.root / "README.md").write_text("x", encoding="utf-8") + names = [p.name for p in cm.find_items(self.root)] + self.assertEqual(names, ["001_example"]) + + def test_main_returns_zero_when_all_conform(self) -> None: + _write_valid_item(self.root, "001_example") + _write_valid_item(self.root, cm.TEMPLATE_NAME) + self.assertEqual(cm.main(["--root", str(self.root)]), 0) + + def test_main_returns_one_on_violation(self) -> None: + item = _write_valid_item(self.root, "001_example") + (item / "description.md").unlink() + self.assertEqual(cm.main(["--root", str(self.root)]), 1) + + def test_main_item_flag_validates_single_item(self) -> None: + _write_valid_item(self.root, "001_ok") + bad = _write_valid_item(self.root, "002_bad") + (bad / "preflight.md").unlink() + self.assertEqual( + cm.main(["--root", str(self.root), "--item", "001_ok"]), 0) + self.assertEqual( + cm.main(["--root", str(self.root), "--item", "002_bad"]), 1) + + def test_main_unknown_root_returns_two(self) -> None: + self.assertEqual(cm.main(["--root", str(self.root / "nope")]), 2) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From c6c991c341c9649a366d8c05b1138a39c31d0447 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:23:42 +0100 Subject: [PATCH 31/70] ci(cm): gate PRs with change-management job - add independent validate-change-management job (ubuntu, Python 3.12, no needs:) running the validator over all items - a non-conforming item fails the PR, including the completion PR --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e714c6bb..9c66c9cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,19 @@ jobs: - run: npx turbo lint working-directory: frontend + validate-change-management: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate change-management items + run: python change-management/.validation/validate_change_management.py + test-backend: needs: [lint-backend, lint-frontend] runs-on: ubuntu-latest From 9ddde15b77cf3a2c5ee7d54fc27e0329474e0cfe Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:24:29 +0100 Subject: [PATCH 32/70] refactor(settings): reuse canonical SettingsPageProps in tab defs Drop the duplicate SettingsSectionProps type; tab render callbacks now use the canonical SettingsPageProps ({ user, onUserUpdated? }) from the page types. No behavior change. --- .../finance/src/features/settings/settingsTabs.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/apps/finance/src/features/settings/settingsTabs.tsx b/frontend/apps/finance/src/features/settings/settingsTabs.tsx index 2bf8355a..afc41767 100644 --- a/frontend/apps/finance/src/features/settings/settingsTabs.tsx +++ b/frontend/apps/finance/src/features/settings/settingsTabs.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { Wallet, UserRound, Lock, Tags } from "lucide-react"; import type { User } from "@gofin/core"; import { canUseFinanceFeatures } from "@gofin/core"; +import type { SettingsPageProps } from "../../types"; import { DefaultBudgetSection } from "./components/DefaultBudgetSection"; import { ProfileSection } from "./components/ProfileSection"; import { PasswordSection } from "./components/PasswordSection"; @@ -10,17 +11,15 @@ import { ExportDataSection } from "./components/ExportDataSection"; export type SettingsTabId = "budget" | "profile" | "password" | "tags"; -/** Props every section render receives; each render uses only what it needs. */ -export interface SettingsSectionProps { - user: User; - onUserUpdated?: () => void; -} - +/** + * Props every section render receives; each render uses only what it needs. + * Reuses the canonical page-level props ({ user, onUserUpdated? }). + */ export interface SettingsTabDefinition { id: SettingsTabId; label: string; icon: typeof Wallet; - render: (props: SettingsSectionProps) => ReactNode; + render: (props: SettingsPageProps) => ReactNode; } // Shared, finance-agnostic tabs. The Profile tab here renders ProfileSection From ca04184c93cf38a1f6bc29165ed605e6cf63dc42 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:24:53 +0100 Subject: [PATCH 33/70] chore(cm): ignore validator pycache artifacts - add .validation/.gitignore for __pycache__/ and *.pyc created when running the validator tests locally --- change-management/.validation/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 change-management/.validation/.gitignore diff --git a/change-management/.validation/.gitignore b/change-management/.validation/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/change-management/.validation/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc From 084e142bc1522cebc49490c95e5ba242c81f89f2 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:27:47 +0100 Subject: [PATCH 34/70] refactor(cm): tidy checklist guard and broaden CLI error catch --- change-management/.tools/generate_execution_log.py | 6 ++++-- .../.tools/test_generate_execution_log.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/change-management/.tools/generate_execution_log.py b/change-management/.tools/generate_execution_log.py index 8a0550b4..f6ae35e3 100755 --- a/change-management/.tools/generate_execution_log.py +++ b/change-management/.tools/generate_execution_log.py @@ -81,7 +81,7 @@ def parse_blocks(text: str) -> list[Block]: stripped = line.strip() if stripped.startswith("## "): # Any level-2 heading toggles checklist capture on only for Checklist:. - in_checklist = stripped.rstrip().lower().startswith("## checklist") + in_checklist = stripped.lower().startswith("## checklist") continue if in_checklist: @@ -232,7 +232,9 @@ def main(argv: list[str] | None = None) -> int: try: output = generate(Path(args.item_folder), force=args.force) - except (FileNotFoundError, FileExistsError) as error: + except OSError as error: + # Covers missing folder/sources (FileNotFoundError), an existing log + # without --force (FileExistsError), and any other IO failure. print(f"error: {error}", file=sys.stderr) return 1 diff --git a/change-management/.tools/test_generate_execution_log.py b/change-management/.tools/test_generate_execution_log.py index 660ed49a..64bb55bb 100755 --- a/change-management/.tools/test_generate_execution_log.py +++ b/change-management/.tools/test_generate_execution_log.py @@ -159,6 +159,16 @@ def test_completion_footer_names_rename_target_and_statuses(self): for status in ("completed", "completed-off-script", "failed", "aborted"): self.assertIn(status, self.log) + def test_missing_validation_renders_visible_marker(self): + log = gen.render_execution_log("001_demo", "# Activity 1: lonely\n\n## Checklist:\n1. do it\n", "") + self.assertIn("**Validation 1: (missing: add a Validation 1 block)**", log) + self.assertIn("- [ ] (no matching validation defined)", log) + + def test_empty_checklist_renders_placeholder_checkbox(self): + # Activity with a title but no ## Checklist: items. + log = gen.render_execution_log("001_demo", "# Activity 1: no steps\n\n## Rollback Plan:\n1. undo\n", "") + self.assertIn("- [ ] (no checklist steps defined)", log) + class GenerateIoTest(unittest.TestCase): def _make_item(self, root: Path) -> Path: From eeddd8e19664593443093ed1fc907bd95f1362d2 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:29:52 +0100 Subject: [PATCH 35/70] test(shell): assert Log Expense FAB is hidden for an assumed session Lock in the documented deviation that the FAB stays hidden during assumption (it shares the bottom-right corner with the Return to Admin control). --- frontend/apps/shell/app/__tests__/auth-layout.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/apps/shell/app/__tests__/auth-layout.test.tsx b/frontend/apps/shell/app/__tests__/auth-layout.test.tsx index 15874934..74ad54ee 100644 --- a/frontend/apps/shell/app/__tests__/auth-layout.test.tsx +++ b/frontend/apps/shell/app/__tests__/auth-layout.test.tsx @@ -349,6 +349,8 @@ describe("AuthLayout - mobile menu and actions", () => { expect(screen.getByText("Return to Admin")).toBeInTheDocument(); // No operator Admin link while acting as a regular user. expect(screen.queryByText("Admin")).not.toBeInTheDocument(); + // FAB stays hidden during assumption (shares the corner with Return to Admin). + expect(screen.queryByLabelText("Log Expense")).not.toBeInTheDocument(); // Logo targets getLandingPath(user) === /dashboard. expect(screen.getByText("GoFin").closest("a")).toHaveAttribute( "href", From d0f5077a354afdf80c20b9e372ef6cdf1ac8f6bd Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:31:06 +0100 Subject: [PATCH 36/70] fix(gateway): fail-safe deny unknown access levels in AccessControl Add a default deny branch to the per-level switch so any Access value that is not explicitly allowed (including a hypothetical future level) returns 403 rather than falling through to c.Next(). Public is still short-circuited before token validation; only Authenticated passes without a role check. Adds a test asserting an out-of-enum access level is denied. --- services/gateway/internal/access/control.go | 14 +++++++--- .../gateway/internal/access/control_test.go | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/services/gateway/internal/access/control.go b/services/gateway/internal/access/control.go index 5b38c167..74f3bce0 100644 --- a/services/gateway/internal/access/control.go +++ b/services/gateway/internal/access/control.go @@ -37,6 +37,9 @@ const ( // 4. otherwise validates the gofin_access cookie (401 on missing/invalid), // 5. injects the validated identity as downstream headers, and // 6. enforces the per-level role check (403 when the role is wrong). +// +// The per-level switch is fail-safe: only Authenticated passes without a role +// check, and any level that is not explicitly allowed is denied (403). func AccessControl(validator TokenValidator, policy Policy, logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { stripIdentityHeaders(c) @@ -81,9 +84,14 @@ func AccessControl(validator TokenValidator, policy Policy, logger *slog.Logger) rejectForbidden(c, logger, result) return } - case Public, Authenticated: - // Public is short-circuited above; Authenticated needs only a valid - // token, which we now have. Neither enforces a role. + case Authenticated: + // Any valid token passes; no role check. + default: + // Fail-safe by construction: Public is short-circuited before token + // validation, so anything reaching here that is not explicitly + // allowed (including an unrecognized future Access value) is denied. + rejectForbidden(c, logger, result) + return } c.Next() diff --git a/services/gateway/internal/access/control_test.go b/services/gateway/internal/access/control_test.go index 44d12ccc..d547da64 100644 --- a/services/gateway/internal/access/control_test.go +++ b/services/gateway/internal/access/control_test.go @@ -349,6 +349,32 @@ func TestAccessControl_Forbidden_LogsWarning(t *testing.T) { assert.Equal(t, "user-1", entry["user_id"]) } +// --- Fail-safe: an unrecognized access level is denied by construction --- + +func TestAccessControl_UnknownLevel_DeniesByDefault(t *testing.T) { + validator := &fakeValidator{ + result: &access.TokenValidationResult{UserID: "user-1", Role: "user"}, + } + + // A policy with no rules whose Default is an out-of-enum access level. + // resolve() returns this Default for every path, so a valid token reaches + // the middleware's switch with a level that matches none of the known + // cases and must fall through to the fail-safe deny. + policy := access.Policy{Default: access.Access(99)} + + engine := gin.New() + engine.Use(access.AccessControl(validator, policy, silentLogger())) + engine.GET("/api/anything", okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/anything", nil) + req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, "unknown access level must be denied") + assert.Contains(t, rec.Body.String(), "FORBIDDEN") +} + // bytesBuffer is a minimal io.Writer that also parses the last JSON log line. type bytesBuffer struct { data []byte From 2a0dd71f23ce2cd3279c2753f437269006718623 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:31:59 +0100 Subject: [PATCH 37/70] test(cm): cover stray-validation rejection paths - assert a Validation N with no matching Activity N fails - assert a validation-only file fails with 'no Activity N block found' --- .../test_validate_change_management.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/change-management/.validation/test_validate_change_management.py b/change-management/.validation/test_validate_change_management.py index 5aab9af8..c9f81025 100644 --- a/change-management/.validation/test_validate_change_management.py +++ b/change-management/.validation/test_validate_change_management.py @@ -154,6 +154,21 @@ def test_unmatched_activity_fails(self) -> None: self.assertIn("Activity 2 has no matching Validation 2", self._rule("001_example") or "") + def test_stray_validation_fails(self) -> None: + item = _write_valid_item(self.root, "001_example") + text = _av_block("Activity", 1) + _av_block("Validation", 1) \ + + _av_block("Validation", 2) + (item / "preflight.md").write_text(text, encoding="utf-8") + self.assertIn("Validation 2 has no matching Activity 2", + self._rule("001_example") or "") + + def test_validation_only_file_fails(self) -> None: + item = _write_valid_item(self.root, "001_example") + text = _av_block("Validation", 1) + _av_block("Validation", 2) + (item / "steps.md").write_text(text, encoding="utf-8") + self.assertIn("no '# Activity N' block found", + self._rule("001_example") or "") + def test_block_missing_checklist_fails(self) -> None: item = _write_valid_item(self.root, "001_example") text = _av_block("Activity", 1).replace("## Checklist:", "## Tasks:") \ From bdf665b60f6141822d7f51c09bb651cd4cbbfbab Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:36:48 +0100 Subject: [PATCH 38/70] feat(cm): author 001 admin-finance-cleanup item - add description.md with all 19 required section headings covering the change event, impact/risk, worst case, and rollback for the admin-owned finance data cleanup - add preflight.md with paired Activity/Validation blocks for merge, deploy, and read-only dry-run pre-check referencing assets/dry-run.sql - add steps.md with paired Activity/Validation blocks for SSH, backup, transactional cleanup.sql, post-check, and completion-PR finalize --- .../001_admin-finance-cleanup/description.md | 138 ++++++++++++++++ .../001_admin-finance-cleanup/preflight.md | 89 ++++++++++ .../001_admin-finance-cleanup/steps.md | 154 ++++++++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 change-management/001_admin-finance-cleanup/description.md create mode 100644 change-management/001_admin-finance-cleanup/preflight.md create mode 100644 change-management/001_admin-finance-cleanup/steps.md diff --git a/change-management/001_admin-finance-cleanup/description.md b/change-management/001_admin-finance-cleanup/description.md new file mode 100644 index 00000000..518cc2db --- /dev/null +++ b/change-management/001_admin-finance-cleanup/description.md @@ -0,0 +1,138 @@ +# Description: 001_admin-finance-cleanup + +## Change Event + +#### What is the purpose of this activity or change? + +Remove all finance data owned by `admin` (operator) accounts as the data-cleanup +step of the operator-only refactor. After the backend refactor, admins can no +longer create finance data; this one-off deletion removes the pre-existing +admin-owned finance rows so the operator identity holds no personal finance +state. + +#### What will be required to execute this change? + +- The operator-only backend code PR merged to `main` and deployed to prod, so no + new admin finance rows can be created between deploy and cleanup. +- SSH access to the production VPS, with the repo checked out at `/opt/gofin`. +- Postgres access on the prod host via `docker compose exec postgresql psql`. +- A fresh database backup/snapshot taken immediately before the deletion. +- The committed assets `assets/dry-run.sql` (read-only pre-check) and + `assets/cleanup.sql` (transactional, admin-scoped delete). + +#### What is the expected end state of the system after this change? + +Zero admin-owned rows in the four `finance` target tables +(`finance.pro_rata_schedules`, `finance.tags`, `finance.budget_periods`, +`finance.default_settings`) and in `datarights.export_jobs`. Admin operator +accounts in `auth.users` remain intact, and audit data in +`datarights.deletion_jobs` (which references `admin_user_id`) is preserved. +Re-running `assets/dry-run.sql` reports all zeros. + +#### What assumptions, if any, are being made about the state of the system at the time of this change? + +- Admin is identified by `auth.users.role = 'admin'` in the same `gofin` + database. +- Production currently holds approximately one admin-owned budget row and no + admin expense data; the preflight dry-run confirms exact counts before the + deletion runs. +- All services share one Postgres database `gofin` (differing only by + `search_path`), so fully-qualified cross-schema deletes run on a single + connection regardless of `search_path`. +- There are no cross-schema foreign keys by project convention; intra-schema + delete order (schedules/tags before periods/settings) is safe. + +#### Rollout Date/Time(s) and Duration + +On demand, once the operator-only backend code PR is deployed to prod. Expected +duration under 30 minutes, including the pre-run backup and all validations. Run +during a low-traffic window. + +## Impact / Risk Assessment + +#### Why is it necessary? What is the impact of not making this change? + +Without it, `admin` (operator) accounts retain personal finance rows that +contradict the operator-only model: the operator identity is meant to own no +personal finance state. Leaving the data in place produces an inconsistent role +model, stale references, and confusing operator accounts. + +#### Why does this activity or change need to be done under Change Management? Can it be safely automated? + +It is a destructive, one-off production data deletion that cannot be trivially +reversed and has no meaningful backward migration. It should not persist in the +codebase as a startup migration or a `just` recipe. It needs a human executing +checklists with validations, a pre-run backup, and a documented rollback, so it +is a change-managed manual operation rather than an automated migration. + +#### Are there any related, prerequisite changes upon which this CM hinges? + +It hinges on the operator-only backend refactor PR being merged and deployed to +prod first, so admins can no longer create finance data in the window between the +deploy and the cleanup. It also depends on the committed `assets/dry-run.sql` and +`assets/cleanup.sql`, and on the safety test that proves their scope and +idempotency before merge. + +#### Will this CM be in any way intrusive, and if so, how will you know? What teams, services or functionality will be impacted? + +The blast radius is limited to admin-owned finance rows. Only operator accounts' +finance data is deleted; regular users' finance data is untouched because every +delete is scoped to admin user ids. Impacted schemas are `finance` and +`datarights`. Impact is detected by comparing the deleted-row counts against the +dry-run and by re-running the dry-run afterward. The deletion is a single atomic +transaction, so a partial failure leaves no partial state. + +#### How has this change been tested to verify it's safe for production? + +A gated Go integration test (`services/dbmigrate/admin_finance_cleanup_test.go`) +runs the exact shipping `assets/cleanup.sql` against a disposable Postgres. It +seeds both an admin and a regular user with matching finance rows and asserts: +all admin finance rows deleted, all regular-user rows intact, the `auth.users` +admin row intact, `datarights.deletion_jobs` audit rows intact, and a second run +deletes zero rows (idempotent). The preflight dry-run re-confirms scope on prod +immediately before execution. + +## Worst Case Scenario + +#### What could happen if everything goes wrong with this change? + +An incorrectly scoped or mistyped statement deletes finance data belonging to +regular users, or deletes more than intended, causing irreversible loss of user +financial records. + +#### How does this CM attempt to mitigate this risk? + +- Every delete is filtered to admin user ids via + `user_id IN (SELECT id FROM auth.users WHERE role = 'admin')`; no regular-user + row can match. +- All deletes run inside one `BEGIN; ... COMMIT;` transaction, so any error rolls + the whole operation back with no partial deletion. +- A full database backup/snapshot is taken immediately before execution, enabling + a restore. +- The scoping and idempotency are proven by the gated integration test, and the + prod dry-run confirms counts before the deletion runs. + +## Rollback Procedure + +#### What conditions would indicate a need to rollback? + +The deleted-row counts do not match the dry-run; the post-check dry-run shows +remaining admin-owned rows or missing regular-user/audit data; an error occurs +mid-transaction; or a service becomes unhealthy after execution. + +#### In the event of problems, what will you do to return your system to a known good state? + +Because the deletion is a single atomic transaction, an error before `COMMIT` +leaves the data untouched, so no data action is needed beyond investigating the +cause. If a committed deletion is later found to be wrong, restore the `gofin` +database from the pre-run backup/snapshot taken in `steps.md`. For the code +deploy, roll back to the previous SHA image per `scripts/deploy.sh`. Revert the +item-creation or completion PRs as needed. + +#### If this is a software or infrastructure change, has the rollback procedure been verified in a development environment? + +The scoping and idempotency were verified by the gated integration test against a +disposable Postgres, including the re-run (no-op) assertion that exercises the +atomic-transaction behavior. The database restore-from-backup path uses the +standard Postgres `pg_dump`/`pg_restore` workflow validated as part of the backup +step in `steps.md`. diff --git a/change-management/001_admin-finance-cleanup/preflight.md b/change-management/001_admin-finance-cleanup/preflight.md new file mode 100644 index 00000000..cbe846e9 --- /dev/null +++ b/change-management/001_admin-finance-cleanup/preflight.md @@ -0,0 +1,89 @@ +# Preflight: 001_admin-finance-cleanup + +> Everything done before the cleanup is executed: merge the operator-only code +> PR, deploy it to prod, and run the read-only dry-run to confirm scope. Each +> `# Activity N` is paired with a `# Validation N`. See `steps.md` for the +> execution itself. + +# Activity 1: Merge the operator-only backend PR to main + +**Description**: Merge the operator-only refactor PR so that, once deployed, +`admin` accounts can no longer create finance data. This closes the window in +which new admin finance rows could appear before the cleanup runs. + +## Checklist: +1. Confirm the operator-only backend PR is approved and up to date with `main`. +2. Merge the PR to `main`. +3. Note the merge commit SHA for the deploy and post-run SHA check. + +## Rollback Plan: +1. Revert the merge commit with a follow-up PR if the change must be undone. + +# Validation 1: CI is green on main + +**Description**: Confirm the merged change built and tested cleanly on `main`, +including the `validate-change-management` job. + +## Checklist: +1. The `main` CI run for the merge commit is green (build, tests, lint). +2. The `validate-change-management` job passed. + +## Rollback Plan: +1. If CI is red on `main`, revert the merge commit and do not proceed to deploy. + +# Activity 2: Deploy the merged code to prod + +**Description**: Deploy the merged `main` to the production VPS so the +operator-only backend is live before any data is deleted. + +## Checklist: +1. Run the deploy (CD on push to `main`, or `scripts/deploy.sh <server-ip>`). +2. Confirm the deployed SHA matches the merge commit from Activity 1 + (`/opt/gofin/.deployed-sha`). + +## Rollback Plan: +1. Roll back to the previous SHA image per `scripts/deploy.sh` (it re-tags the + prior `sha-<PREV_SHA>` images to `latest` and recreates the stack). + +# Validation 2: Prod services healthy and app reachable + +**Description**: Confirm the deploy left every service healthy and the app +reachable before touching data. + +## Checklist: +1. On the VPS, `cd /opt/gofin && docker compose ps` shows all services healthy. +2. The app responds over its public URL (login page loads). + +## Rollback Plan: +1. If any service is unhealthy or the app is unreachable, roll back to the + previous SHA image per `scripts/deploy.sh` and do not proceed to the dry-run. + +# Activity 3: Run the read-only dry-run pre-check on prod + +**Description**: Run `assets/dry-run.sql` on prod to report admin-owned row +counts in every cleanup target. This mutates nothing and confirms the scope +before execution. + +## Checklist: +1. On the VPS at `/opt/gofin`, run: + `docker compose exec -T postgresql psql -U gofin -d gofin < change-management/001_admin-finance-cleanup/assets/dry-run.sql` +2. Record the per-table counts for comparison against the deletion in `steps.md`. + +## Rollback Plan: +1. None required: the dry-run is read-only. If it fails to run, investigate DB + connectivity and re-run before proceeding. + +# Validation 3: Admin-owned counts match expectation + +**Description**: Confirm the dry-run counts match the expected scope (roughly one +admin-owned budget row, all other targets zero). + +## Checklist: +1. `finance.budget_periods` reports approximately 1 admin-owned row. +2. `finance.pro_rata_schedules`, `finance.tags`, `finance.default_settings`, and + `datarights.export_jobs` report the expected counts (0 unless known otherwise). + +## Rollback Plan: +1. If any count is unexpected, stop: do not run `cleanup.sql`. Investigate the + anomaly (e.g. an admin created finance data before deploy) and resolve it + before re-running the dry-run. diff --git a/change-management/001_admin-finance-cleanup/steps.md b/change-management/001_admin-finance-cleanup/steps.md new file mode 100644 index 00000000..b137281b --- /dev/null +++ b/change-management/001_admin-finance-cleanup/steps.md @@ -0,0 +1,154 @@ +# Steps: 001_admin-finance-cleanup + +> The execution itself: connect to prod, back up the database, run the +> transactional cleanup, post-check, then finalize via the completion PR. Each +> `# Activity N` is paired with a `# Validation N`. Preflight (merge, deploy, +> dry-run) is done in `preflight.md` before starting here. + +# Activity 1: SSH to the VPS and enter the repo + +**Description**: Connect to the production VPS and change into the deployed repo +so subsequent `docker compose` commands run against the prod stack. + +## Checklist: +1. SSH to the production VPS. +2. `cd /opt/gofin`. + +## Rollback Plan: +1. None: connecting and changing directory make no changes. Disconnect if the + host or repo state is wrong. + +# Validation 1: Correct host and repo SHA + +**Description**: Confirm you are on the intended prod host and the deployed SHA +matches the change deployed in preflight. + +## Checklist: +1. `hostname` (or the cloud console) confirms the intended production host. +2. `cat /opt/gofin/.deployed-sha` matches the merge commit SHA from preflight + Activity 1. + +## Rollback Plan: +1. If the host or SHA is wrong, stop and do not proceed; re-verify the deploy + before continuing. + +# Activity 2: Take a database backup/snapshot + +**Description**: Take a fresh, restorable backup of the `gofin` database +immediately before the deletion so the state can be restored if needed. + +## Checklist: +1. At `/opt/gofin`, dump the database, e.g.: + `docker compose exec -T postgresql pg_dump -U gofin -Fc gofin > backups/gofin-pre-001-$(date +%Y%m%d%H%M%S).dump` +2. Record the backup artifact path and size. + +## Rollback Plan: +1. None: taking a backup is non-destructive. If the dump fails, do not proceed to + the cleanup until a valid backup exists. + +# Validation 2: Backup artifact exists and is restorable + +**Description**: Confirm the backup was written and is a valid, restorable +archive before any data is deleted. + +## Checklist: +1. The dump file exists and is non-empty. +2. `pg_restore -l <dump>` (or a restore into a scratch database) lists the + archive contents without error. + +## Rollback Plan: +1. If the backup is missing or unreadable, stop and re-take it; do not run + `cleanup.sql` without a verified backup. + +# Activity 3: Run cleanup.sql in a transaction + +**Description**: Run `assets/cleanup.sql` against prod Postgres. It wraps the +five admin-scoped deletes in a single `BEGIN; ... COMMIT;` so the operation is +atomic. + +## Checklist: +1. At `/opt/gofin`, run: + `docker compose exec -T postgresql psql -U gofin -d gofin < change-management/001_admin-finance-cleanup/assets/cleanup.sql` +2. Capture the per-statement `DELETE <n>` row counts and the final `COMMIT`. + +## Rollback Plan: +1. Any error before `COMMIT` rolls the whole transaction back automatically (no + partial deletion): investigate and re-run. +2. If a committed deletion is later found to be wrong, restore the `gofin` + database from the Activity 2 backup with `pg_restore`. + +# Validation 3: Deleted-row counts match the dry-run + +**Description**: Confirm the deleted counts match the admin-owned counts reported +by the preflight dry-run. + +## Checklist: +1. The `DELETE` counts per table equal the preflight dry-run counts (e.g. + `finance.budget_periods` = approximately 1, others as reported). +2. The transaction ended with `COMMIT` (no rollback/error). + +## Rollback Plan: +1. If counts do not match or the transaction errored, do not finalize: restore + from the Activity 2 backup if needed and investigate before re-running. + +# Activity 4: Post-check by re-running the dry-run + +**Description**: Re-run `assets/dry-run.sql` to confirm the cleanup removed every +admin-owned finance row and left audit data intact. + +## Checklist: +1. At `/opt/gofin`, run: + `docker compose exec -T postgresql psql -U gofin -d gofin < change-management/001_admin-finance-cleanup/assets/dry-run.sql` +2. Spot-check that `auth.users` admin rows and `datarights.deletion_jobs` audit + rows are still present. + +## Rollback Plan: +1. If admin-owned finance rows remain or expected audit/user data is missing, + restore from the Activity 2 backup and investigate; complete the item as + `failed` rather than `completed`. + +# Validation 4: Zero admin-owned rows; audit data intact + +**Description**: Confirm the end state matches the expected outcome. + +## Checklist: +1. The dry-run reports 0 admin-owned rows for all five target tables. +2. `auth.users` admin (operator) rows and `datarights.deletion_jobs` audit rows + are intact. + +## Rollback Plan: +1. If any check fails, restore from the Activity 2 backup and complete the item + as `failed` with notes; do not rename the folder `completed`. + +# Activity 5: Finalize via the completion PR + +**Description**: Finalize the change in the repo: remove the temporary safety +test, add the filled-in `execution-log.md`, and rename the folder with the +outcome status suffix. + +## Checklist: +1. Generate and fill in `execution-log.md` + (`python3 change-management/.tools/generate_execution_log.py change-management/001_admin-finance-cleanup`). +2. Remove the temporary safety test + `services/dbmigrate/admin_finance_cleanup_test.go`. +3. `git mv change-management/001_admin-finance-cleanup change-management/001_admin-finance-cleanup_completed`. +4. Open the completion PR with the rename and the filled `execution-log.md`. + +## Rollback Plan: +1. Revert the completion PR if any finalization step is wrong; the executed data + change is unaffected by reverting repo housekeeping. + +# Validation 5: CI green including validate-change-management + +**Description**: Confirm the completion PR passes CI, including the +change-management validator, which requires a non-empty `execution-log.md` for a +status-suffixed folder. + +## Checklist: +1. CI on the completion PR is green. +2. The `validate-change-management` job passes for + `001_admin-finance-cleanup_completed`. + +## Rollback Plan: +1. If `validate-change-management` fails, fix the `execution-log.md` or folder + name on the PR branch and re-run; do not merge until green. From 810d15777b3caa2edf480e1842392c834b556c6c Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:40:26 +0100 Subject: [PATCH 39/70] feat(gateway): wire AccessControl into router - Install access.AccessControl(validator, DefaultPolicy(), logger) as the final global middleware (after Recovery, HTTPMetrics, RequestLogger), replacing middleware.Auth and the per-group RequireAdmin/AdminRouteGuard - Delete middleware/auth.go and middleware/admin.go; groups are now pure proxy wiring and router.New takes access.TokenValidator - Retarget router tests to access.* and assert direct admins get 403 on Personal routes; drop the now-obsolete auth/admin guard unit tests --- services/gateway/cmd/grpc_validator.go | 8 +- services/gateway/internal/middleware/admin.go | 90 ------ .../gateway/internal/middleware/admin_test.go | 272 ------------------ services/gateway/internal/middleware/auth.go | 104 ------- .../gateway/internal/middleware/auth_test.go | 255 ---------------- services/gateway/internal/router/router.go | 25 +- .../gateway/internal/router/router_test.go | 44 ++- 7 files changed, 52 insertions(+), 746 deletions(-) delete mode 100644 services/gateway/internal/middleware/admin.go delete mode 100644 services/gateway/internal/middleware/admin_test.go delete mode 100644 services/gateway/internal/middleware/auth.go delete mode 100644 services/gateway/internal/middleware/auth_test.go diff --git a/services/gateway/cmd/grpc_validator.go b/services/gateway/cmd/grpc_validator.go index 95914308..c371570c 100644 --- a/services/gateway/cmd/grpc_validator.go +++ b/services/gateway/cmd/grpc_validator.go @@ -7,10 +7,10 @@ import ( "google.golang.org/grpc" "github.com/ItsThompson/gofin/services/auth/proto/authpb" - "github.com/ItsThompson/gofin/services/gateway/internal/middleware" + "github.com/ItsThompson/gofin/services/gateway/internal/access" ) -// GRPCTokenValidator implements middleware.TokenValidator by calling the +// GRPCTokenValidator implements access.TokenValidator by calling the // auth service's ValidateToken gRPC endpoint. type GRPCTokenValidator struct { client authpb.AuthServiceClient @@ -25,7 +25,7 @@ func NewGRPCTokenValidator(conn *grpc.ClientConn) *GRPCTokenValidator { // ValidateToken calls the auth service to validate an access token. // Returns the user identity on success or an error on failure. -func (v *GRPCTokenValidator) ValidateToken(ctx context.Context, accessToken string) (*middleware.TokenValidationResult, error) { +func (v *GRPCTokenValidator) ValidateToken(ctx context.Context, accessToken string) (*access.TokenValidationResult, error) { resp, err := v.client.ValidateToken(ctx, &authpb.ValidateTokenRequest{ AccessToken: accessToken, }) @@ -33,7 +33,7 @@ func (v *GRPCTokenValidator) ValidateToken(ctx context.Context, accessToken stri return nil, fmt.Errorf("auth service validation failed: %w", err) } - return &middleware.TokenValidationResult{ + return &access.TokenValidationResult{ UserID: resp.GetUserId(), Role: resp.GetRole(), Username: resp.GetUsername(), diff --git a/services/gateway/internal/middleware/admin.go b/services/gateway/internal/middleware/admin.go deleted file mode 100644 index c281cc2b..00000000 --- a/services/gateway/internal/middleware/admin.go +++ /dev/null @@ -1,90 +0,0 @@ -package middleware - -import ( - "log/slog" - "net/http" - "strings" - - "github.com/gin-gonic/gin" -) - -// adminOnlyRoutes lists method+path pairs outside /api/admin/* that require admin role. -// These are checked by AdminRouteGuard to enforce admin access on routes that -// live under other prefixes (e.g., /api/auth/assume). -var adminOnlyRoutes = []struct { - method string - path string -}{ - {method: http.MethodPost, path: "/api/auth/assume"}, -} - -// adminOnlyPrefixes lists URL path prefixes that require admin role. -// Any request whose path starts with one of these prefixes is subject to -// admin enforcement (used for routes with dynamic segments like :id). -var adminOnlyPrefixes = []string{ - "/api/datarights/deletions", -} - -// isAdminOnlyRoute checks whether a given method+path pair requires admin role. -// It checks both exact matches from adminOnlyRoutes and prefix matches from -// adminOnlyPrefixes. -func isAdminOnlyRoute(method, path string) bool { - for _, route := range adminOnlyRoutes { - if route.method == method && route.path == path { - return true - } - } - for _, prefix := range adminOnlyPrefixes { - if strings.HasPrefix(path, prefix) { - return true - } - } - return false -} - -// rejectNonAdmin aborts the request with a 403 FORBIDDEN response. -func rejectNonAdmin(c *gin.Context, logger *slog.Logger) { - logger.Warn("admin access denied", - slog.String("method", c.Request.Method), - slog.String("path", c.Request.URL.Path), - slog.String("role", c.Request.Header.Get("X-User-Role")), - slog.String("user_id", c.Request.Header.Get("X-User-ID")), - ) - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ - "code": "FORBIDDEN", - "message": "Admin access required", - }) -} - -// RequireAdmin returns Gin middleware that rejects requests where X-User-Role -// is not "admin". Apply to route groups like /api/admin/*. -// This middleware must run after Auth middleware, which sets the X-User-Role header. -func RequireAdmin(logger *slog.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - role := c.Request.Header.Get("X-User-Role") - if role != "admin" { - rejectNonAdmin(c, logger) - return - } - - c.Next() - } -} - -// AdminRouteGuard returns Gin middleware that enforces admin role on specific -// routes that live outside the /api/admin/* prefix. It checks the request -// against both the exact adminOnlyRoutes list (e.g., POST /api/auth/assume) -// and the adminOnlyPrefixes list (e.g., /api/datarights/deletions*). -func AdminRouteGuard(logger *slog.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - if isAdminOnlyRoute(c.Request.Method, c.Request.URL.Path) { - role := c.Request.Header.Get("X-User-Role") - if role != "admin" { - rejectNonAdmin(c, logger) - return - } - } - - c.Next() - } -} diff --git a/services/gateway/internal/middleware/admin_test.go b/services/gateway/internal/middleware/admin_test.go deleted file mode 100644 index 32cb4f68..00000000 --- a/services/gateway/internal/middleware/admin_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package middleware_test - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - - "github.com/ItsThompson/gofin/services/gateway/internal/middleware" -) - -func TestRequireAdmin_AdminRole_Passes(t *testing.T) { - router := gin.New() - router.Use(middleware.RequireAdmin(newSilentLogger())) - router.GET("/api/admin/users", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) - req.Header.Set("X-User-Role", "admin") - req.Header.Set("X-User-ID", "admin-123") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) -} - -func TestRequireAdmin_UserRole_Returns403(t *testing.T) { - router := gin.New() - router.Use(middleware.RequireAdmin(newSilentLogger())) - router.GET("/api/admin/users", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) - req.Header.Set("X-User-Role", "user") - req.Header.Set("X-User-ID", "user-456") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.Contains(t, recorder.Body.String(), "FORBIDDEN") -} - -func TestRequireAdmin_MissingRole_Returns403(t *testing.T) { - router := gin.New() - router.Use(middleware.RequireAdmin(newSilentLogger())) - router.GET("/api/admin/users", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.Contains(t, recorder.Body.String(), "FORBIDDEN") -} - -func TestRequireAdmin_EmptyRole_Returns403(t *testing.T) { - router := gin.New() - router.Use(middleware.RequireAdmin(newSilentLogger())) - router.GET("/api/admin/users", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) - req.Header.Set("X-User-Role", "") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusForbidden, recorder.Code) -} - -func TestAdminRouteGuard_AssumeEndpoint_AdminPasses(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.POST("/api/auth/assume", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodPost, "/api/auth/assume", nil) - req.Header.Set("X-User-Role", "admin") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) -} - -func TestAdminRouteGuard_AssumeEndpoint_UserReturns403(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.POST("/api/auth/assume", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodPost, "/api/auth/assume", nil) - req.Header.Set("X-User-Role", "user") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.Contains(t, recorder.Body.String(), "FORBIDDEN") -} - -func TestAdminRouteGuard_NonAdminRoute_Passes(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.POST("/api/auth/login", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - // POST /api/auth/login is not admin-only, so even 'user' role passes. - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) - req.Header.Set("X-User-Role", "user") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) -} - -// --- Prefix-based matching tests for /api/datarights/deletions --- - -func TestAdminRouteGuard_DeletionsPost_AdminPasses(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.POST("/api/datarights/deletions", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodPost, "/api/datarights/deletions", nil) - req.Header.Set("X-User-Role", "admin") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) -} - -func TestAdminRouteGuard_DeletionsPost_UserReturns403(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.POST("/api/datarights/deletions", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodPost, "/api/datarights/deletions", nil) - req.Header.Set("X-User-Role", "user") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.Contains(t, recorder.Body.String(), "FORBIDDEN") -} - -func TestAdminRouteGuard_DeletionsGetByID_AdminPasses(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.GET("/api/datarights/deletions/:id", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/datarights/deletions/abc-123", nil) - req.Header.Set("X-User-Role", "admin") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) -} - -func TestAdminRouteGuard_DeletionsGetByID_UserReturns403(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.GET("/api/datarights/deletions/:id", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/datarights/deletions/abc-123", nil) - req.Header.Set("X-User-Role", "user") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusForbidden, recorder.Code) - assert.Contains(t, recorder.Body.String(), "FORBIDDEN") -} - -// --- Export routes passthrough (not blocked by AdminRouteGuard) --- - -func TestAdminRouteGuard_ExportsPost_UserPasses(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.POST("/api/datarights/exports", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodPost, "/api/datarights/exports", nil) - req.Header.Set("X-User-Role", "user") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) -} - -func TestAdminRouteGuard_ExportsGetByID_UserPasses(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.GET("/api/datarights/exports/:id", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/datarights/exports/export-456", nil) - req.Header.Set("X-User-Role", "user") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) -} - -// --- Backward compatibility: exact route matching still works --- - -func TestAdminRouteGuard_ExactMatch_StillWorks(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.POST("/api/auth/assume", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - router.GET("/api/auth/me", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - // POST /api/auth/assume with user role: blocked - req := httptest.NewRequest(http.MethodPost, "/api/auth/assume", nil) - req.Header.Set("X-User-Role", "user") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - assert.Equal(t, http.StatusForbidden, recorder.Code) - - // GET /api/auth/me with user role: passes (not in adminOnlyRoutes) - req = httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) - req.Header.Set("X-User-Role", "user") - recorder = httptest.NewRecorder() - router.ServeHTTP(recorder, req) - assert.Equal(t, http.StatusOK, recorder.Code) -} - -// --- Prefix matching is method-agnostic (any HTTP method on deletion paths is blocked) --- - -func TestAdminRouteGuard_DeletionsAnyMethod_UserReturns403(t *testing.T) { - router := gin.New() - router.Use(middleware.AdminRouteGuard(newSilentLogger())) - router.GET("/api/datarights/deletions", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - router.DELETE("/api/datarights/deletions/:id", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - // GET /api/datarights/deletions with user role: blocked - req := httptest.NewRequest(http.MethodGet, "/api/datarights/deletions", nil) - req.Header.Set("X-User-Role", "user") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - assert.Equal(t, http.StatusForbidden, recorder.Code) - - // DELETE /api/datarights/deletions/:id with user role: blocked - req = httptest.NewRequest(http.MethodDelete, "/api/datarights/deletions/abc-123", nil) - req.Header.Set("X-User-Role", "user") - recorder = httptest.NewRecorder() - router.ServeHTTP(recorder, req) - assert.Equal(t, http.StatusForbidden, recorder.Code) -} diff --git a/services/gateway/internal/middleware/auth.go b/services/gateway/internal/middleware/auth.go deleted file mode 100644 index ea7f37bf..00000000 --- a/services/gateway/internal/middleware/auth.go +++ /dev/null @@ -1,104 +0,0 @@ -package middleware - -import ( - "log/slog" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ItsThompson/gofin/services/gateway/internal/access" -) - -// TokenValidationResult and TokenValidator have moved to the access package, -// their canonical home (they are consumed by access.AccessControl). These -// aliases keep middleware.Auth, the gRPC client, and existing callers -// compiling during the gateway access-control refactor; they are removed -// together with this file in the router-cutover ticket. -type ( - TokenValidationResult = access.TokenValidationResult - TokenValidator = access.TokenValidator -) - -// unauthenticatedRoute defines a method+path pair that bypasses auth validation. -type unauthenticatedRoute struct { - method string - path string -} - -// unauthenticatedRoutes lists the routes that skip auth validation per the spec: -// registration, login, and token refresh. -var unauthenticatedRoutes = []unauthenticatedRoute{ - {method: http.MethodPost, path: "/api/auth/register"}, - {method: http.MethodPost, path: "/api/auth/login"}, - {method: http.MethodPost, path: "/api/auth/refresh"}, - {method: http.MethodGet, path: "/health"}, - {method: http.MethodGet, path: "/metrics"}, -} - -// isUnauthenticatedRoute checks whether a given method+path pair is exempt from auth. -func isUnauthenticatedRoute(method, path string) bool { - for _, route := range unauthenticatedRoutes { - if route.method == method && route.path == path { - return true - } - } - return false -} - -// Auth returns Gin middleware that validates the access token on every request -// (except unauthenticated exceptions). On success, it injects X-User-ID and -// X-User-Role headers into the request before forwarding to downstream services. -func Auth(validator TokenValidator, logger *slog.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - // Strip identity headers on every request to prevent spoofing. - // These are only set by the gateway after successful validation. - c.Request.Header.Del("X-User-ID") - c.Request.Header.Del("X-User-Role") - c.Request.Header.Del("X-Assumed-By") - - if isUnauthenticatedRoute(c.Request.Method, c.Request.URL.Path) { - c.Next() - return - } - - cookie, err := c.Request.Cookie("gofin_access") - if err != nil || cookie.Value == "" { - logger.Warn("missing access token cookie", - slog.String("method", c.Request.Method), - slog.String("path", c.Request.URL.Path), - ) - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "code": "UNAUTHORIZED", - "message": "Authentication required", - }) - return - } - - result, err := validator.ValidateToken(c.Request.Context(), cookie.Value) - if err != nil { - logger.Warn("token validation failed", - slog.String("method", c.Request.Method), - slog.String("path", c.Request.URL.Path), - slog.String("error", err.Error()), - ) - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "code": "UNAUTHORIZED", - "message": "Invalid or expired token", - }) - return - } - - // Inject identity headers for downstream services. - c.Request.Header.Set("X-User-ID", result.UserID) - c.Request.Header.Set("X-User-Role", result.Role) - - // Store in Gin context so the logging middleware can read it. - c.Set("X-User-ID", result.UserID) - - if result.AssumedBy != "" { - c.Request.Header.Set("X-Assumed-By", result.AssumedBy) - } - - c.Next() - } -} diff --git a/services/gateway/internal/middleware/auth_test.go b/services/gateway/internal/middleware/auth_test.go deleted file mode 100644 index b879a7b1..00000000 --- a/services/gateway/internal/middleware/auth_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package middleware_test - -import ( - "context" - "fmt" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - - "github.com/ItsThompson/gofin/services/gateway/internal/middleware" -) - -// mockTokenValidator implements middleware.TokenValidator for tests. -type mockTokenValidator struct { - result *middleware.TokenValidationResult - err error -} - -func (m *mockTokenValidator) ValidateToken(_ context.Context, _ string) (*middleware.TokenValidationResult, error) { - return m.result, m.err -} - -func newSilentLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -func TestAuth_ValidToken_InjectsHeaders(t *testing.T) { - validator := &mockTokenValidator{ - result: &middleware.TokenValidationResult{ - UserID: "user-abc", - Role: "user", - Username: "alice", - }, - } - - var capturedUserID, capturedRole string - - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.GET("/api/test", func(c *gin.Context) { - capturedUserID = c.Request.Header.Get("X-User-ID") - capturedRole = c.Request.Header.Get("X-User-Role") - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "valid-token"}) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) - assert.Equal(t, "user-abc", capturedUserID) - assert.Equal(t, "user", capturedRole) -} - -func TestAuth_ValidToken_SetsAssumedByHeader(t *testing.T) { - validator := &mockTokenValidator{ - result: &middleware.TokenValidationResult{ - UserID: "target-user", - Role: "user", - Username: "bob", - AssumedBy: "admin-123", - }, - } - - var capturedAssumedBy string - - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.GET("/api/test", func(c *gin.Context) { - capturedAssumedBy = c.Request.Header.Get("X-Assumed-By") - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "assumed-token"}) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) - assert.Equal(t, "admin-123", capturedAssumedBy) -} - -func TestAuth_ExpiredToken_Returns401(t *testing.T) { - validator := &mockTokenValidator{ - err: fmt.Errorf("token expired"), - } - - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.GET("/api/test", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "expired-token"}) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusUnauthorized, recorder.Code) - assert.Contains(t, recorder.Body.String(), "UNAUTHORIZED") -} - -func TestAuth_InvalidToken_Returns401(t *testing.T) { - validator := &mockTokenValidator{ - err: fmt.Errorf("invalid token"), - } - - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.GET("/api/test", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "garbage"}) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusUnauthorized, recorder.Code) - assert.Contains(t, recorder.Body.String(), "UNAUTHORIZED") -} - -func TestAuth_MissingCookie_Returns401(t *testing.T) { - validator := &mockTokenValidator{} - - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.GET("/api/test", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusUnauthorized, recorder.Code) - assert.Contains(t, recorder.Body.String(), "UNAUTHORIZED") -} - -func TestAuth_UnauthenticatedRoutes_BypassValidation(t *testing.T) { - validator := &mockTokenValidator{ - err: fmt.Errorf("should not be called"), - } - - tests := []struct { - method string - path string - }{ - {http.MethodPost, "/api/auth/register"}, - {http.MethodPost, "/api/auth/login"}, - {http.MethodPost, "/api/auth/refresh"}, - } - - for _, tt := range tests { - t.Run(tt.method+" "+tt.path, func(t *testing.T) { - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.Handle(tt.method, tt.path, func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - req := httptest.NewRequest(tt.method, tt.path, nil) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) - }) - } -} - -func TestAuth_UnauthenticatedRoute_WrongMethod_RequiresAuth(t *testing.T) { - validator := &mockTokenValidator{ - err: fmt.Errorf("no token"), - } - - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.GET("/api/auth/register", func(c *gin.Context) { - c.Status(http.StatusOK) - }) - - // GET /api/auth/register is NOT unauthenticated: only POST is. - req := httptest.NewRequest(http.MethodGet, "/api/auth/register", nil) - req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "token"}) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusUnauthorized, recorder.Code) -} - -func TestAuth_StripsIdentityHeaders_OnUnauthenticatedRoutes(t *testing.T) { - validator := &mockTokenValidator{} - - var capturedUserID, capturedRole, capturedAssumedBy string - - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.POST("/api/auth/register", func(c *gin.Context) { - capturedUserID = c.Request.Header.Get("X-User-ID") - capturedRole = c.Request.Header.Get("X-User-Role") - capturedAssumedBy = c.Request.Header.Get("X-Assumed-By") - c.Status(http.StatusOK) - }) - - // Client spoofs identity headers on an unauthenticated route. - req := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil) - req.Header.Set("X-User-ID", "spoofed-user") - req.Header.Set("X-User-Role", "admin") - req.Header.Set("X-Assumed-By", "spoofed-admin") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) - assert.Empty(t, capturedUserID, "X-User-ID should be stripped") - assert.Empty(t, capturedRole, "X-User-Role should be stripped") - assert.Empty(t, capturedAssumedBy, "X-Assumed-By should be stripped") -} - -func TestAuth_StripsIdentityHeaders_BeforeValidation(t *testing.T) { - validator := &mockTokenValidator{ - result: &middleware.TokenValidationResult{ - UserID: "real-user", - Role: "user", - Username: "alice", - }, - } - - var capturedUserID, capturedRole string - - router := gin.New() - router.Use(middleware.Auth(validator, newSilentLogger())) - router.GET("/api/test", func(c *gin.Context) { - capturedUserID = c.Request.Header.Get("X-User-ID") - capturedRole = c.Request.Header.Get("X-User-Role") - c.Status(http.StatusOK) - }) - - // Client spoofs headers, but auth succeeds with different identity. - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - req.AddCookie(&http.Cookie{Name: "gofin_access", Value: "valid-token"}) - req.Header.Set("X-User-ID", "spoofed-admin") - req.Header.Set("X-User-Role", "admin") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusOK, recorder.Code) - assert.Equal(t, "real-user", capturedUserID, "should use validated identity, not spoofed") - assert.Equal(t, "user", capturedRole, "should use validated role, not spoofed") -} diff --git a/services/gateway/internal/router/router.go b/services/gateway/internal/router/router.go index 208f7b10..5739673e 100644 --- a/services/gateway/internal/router/router.go +++ b/services/gateway/internal/router/router.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/ItsThompson/gofin/services/gateway/internal/access" "github.com/ItsThompson/gofin/services/gateway/internal/middleware" "github.com/ItsThompson/gofin/services/gateway/internal/proxy" "github.com/ItsThompson/gofin/services/metrics" @@ -23,7 +24,7 @@ type ServiceURLs struct { // New creates a configured Gin engine with all gateway routes, middleware, // and reverse proxy handlers wired up. func New( - validator middleware.TokenValidator, + validator access.TokenValidator, serviceURLs *ServiceURLs, logger *slog.Logger, isProduction bool, @@ -37,12 +38,15 @@ func New( engine.Use(gin.Recovery()) engine.Use(metrics.HTTPMetrics()) engine.Use(middleware.RequestLogger(logger)) - engine.Use(middleware.Auth(validator, logger)) + // AccessControl is the single global gate: it resolves each route against the + // canonical policy table and enforces Public/Authenticated/Personal/Admin, + // replacing the former per-request auth + per-group admin guards. + engine.Use(access.AccessControl(validator, access.DefaultPolicy(), logger)) - // Prometheus metrics endpoint (excluded from auth middleware via exception list). + // Prometheus metrics endpoint (Public in the policy table). metrics.Register(engine) - // Health check endpoint: auth middleware skips it via the exception list. + // Health check endpoint (Public in the policy table). engine.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) @@ -53,20 +57,18 @@ func New( financeProxy := proxy.NewServiceProxy(serviceURLs.FinanceREST, logger) datarightsProxy := proxy.NewServiceProxy(serviceURLs.DatarightsREST, logger) + // Route groups are pure proxy wiring: access is enforced globally by + // AccessControl against the policy table, not per group. + // /api/auth/* → Auth service (REST) - // Some auth routes are unauthenticated (register, login, refresh) and - // bypass the auth middleware via the exception list in auth.go. - // POST /api/auth/assume requires admin role via AdminRouteGuard. authGroup := engine.Group("/api/auth") - authGroup.Use(middleware.AdminRouteGuard(logger)) { authGroup.Any("", ginWrapHandler(authProxy)) authGroup.Any("/*path", ginWrapHandler(authProxy)) } - // /api/admin/* → Auth service (REST), admin-only + // /api/admin/* → Auth service (REST) adminGroup := engine.Group("/api/admin") - adminGroup.Use(middleware.RequireAdmin(logger)) { adminGroup.Any("", ginWrapHandler(authProxy)) adminGroup.Any("/*path", ginWrapHandler(authProxy)) @@ -87,10 +89,7 @@ func New( } // /api/datarights/* → Datarights service (REST) - // AdminRouteGuard enforces admin role on /api/datarights/deletions* paths. - // Export routes (/api/datarights/exports*) remain accessible to all authenticated users. datarightsGroup := engine.Group("/api/datarights") - datarightsGroup.Use(middleware.AdminRouteGuard(logger)) { datarightsGroup.Any("", ginWrapHandler(datarightsProxy)) datarightsGroup.Any("/*path", ginWrapHandler(datarightsProxy)) diff --git a/services/gateway/internal/router/router_test.go b/services/gateway/internal/router/router_test.go index c675ae7e..9699bbc6 100644 --- a/services/gateway/internal/router/router_test.go +++ b/services/gateway/internal/router/router_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/ItsThompson/gofin/services/gateway/internal/middleware" + "github.com/ItsThompson/gofin/services/gateway/internal/access" "github.com/ItsThompson/gofin/services/gateway/internal/router" ) @@ -19,13 +19,13 @@ func newSilentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// mockValidator implements middleware.TokenValidator for router tests. +// mockValidator implements access.TokenValidator for router tests. type mockValidator struct { - result *middleware.TokenValidationResult + result *access.TokenValidationResult err error } -func (m *mockValidator) ValidateToken(_ context.Context, _ string) (*middleware.TokenValidationResult, error) { +func (m *mockValidator) ValidateToken(_ context.Context, _ string) (*access.TokenValidationResult, error) { return m.result, m.err } @@ -35,7 +35,7 @@ func validCookie() *http.Cookie { func adminValidator() *mockValidator { return &mockValidator{ - result: &middleware.TokenValidationResult{ + result: &access.TokenValidationResult{ UserID: "admin-1", Role: "admin", Username: "admin", @@ -45,7 +45,7 @@ func adminValidator() *mockValidator { func userValidator() *mockValidator { return &mockValidator{ - result: &middleware.TokenValidationResult{ + result: &access.TokenValidationResult{ UserID: "user-1", Role: "user", Username: "alice", @@ -55,7 +55,7 @@ func userValidator() *mockValidator { // setupGateway creates a full gateway test server backed by downstream httptest servers. // It returns a doRequest helper and cleans up all servers on test completion. -func setupGateway(t *testing.T, validator middleware.TokenValidator) func(method, path string, cookie *http.Cookie) (*http.Response, string) { +func setupGateway(t *testing.T, validator access.TokenValidator) func(method, path string, cookie *http.Cookie) (*http.Response, string) { t.Helper() // Each downstream echoes its service name in a header so tests can verify routing. @@ -211,7 +211,7 @@ func TestRouter_DatarightsRoutes_RouteToDatarightsService(t *testing.T) { } } -func TestRouter_AdminRoutes_RequireAdminRole(t *testing.T) { +func TestRouter_AdminRoutes_AdminRolePasses(t *testing.T) { doRequest := setupGateway(t, adminValidator()) resp, _ := doRequest(http.MethodGet, "/api/admin/users", validCookie()) @@ -226,6 +226,34 @@ func TestRouter_AdminRoutes_RejectNonAdmin(t *testing.T) { assert.Equal(t, http.StatusForbidden, resp.StatusCode) } +// TestRouter_PersonalRoutes_RejectDirectAdmin is the observable cutover: a +// direct admin (role=="admin", not an assumed session) is now forbidden from +// Personal APIs, where the old admin-as-superset model let them through. The +// request is denied at the gateway and never reaches the downstream service. +func TestRouter_PersonalRoutes_RejectDirectAdmin(t *testing.T) { + doRequest := setupGateway(t, adminValidator()) + + personalRoutes := []struct { + method string + path string + }{ + {http.MethodGet, "/api/finance/periods/current"}, + {http.MethodGet, "/api/expenses/"}, + {http.MethodPost, "/api/datarights/exports"}, + {http.MethodPost, "/api/auth/onboarding-complete"}, + } + + for _, tt := range personalRoutes { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + resp, body := doRequest(tt.method, tt.path, validCookie()) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Contains(t, body, "FORBIDDEN") + assert.Empty(t, resp.Header.Get("X-Downstream"), + "denied request must not reach a downstream service") + }) + } +} + func TestRouter_AssumeEndpoint_RequiresAdmin(t *testing.T) { doRequest := setupGateway(t, userValidator()) From 9c526cab41960e66cb82e1a4da82f19c5a00acb4 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:40:34 +0100 Subject: [PATCH 40/70] docs(auth): fix stale gateway guard references - ListUsers/AssumeIdentity comments claimed the gateway enforced admin via RequireAdmin/AdminRouteGuard middleware, both deleted in the cutover - Describe the current mechanism: the gateway's centralized access-control policy table (/api/admin and POST /api/auth/assume are Admin-only) --- services/auth/internal/handler/rest.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/auth/internal/handler/rest.go b/services/auth/internal/handler/rest.go index 27ca707a..9be51e91 100644 --- a/services/auth/internal/handler/rest.go +++ b/services/auth/internal/handler/rest.go @@ -282,7 +282,8 @@ func (h *RESTHandler) Logout(c *gin.Context) { } // ListUsers handles GET /api/admin/users. -// Returns all registered users. The gateway enforces admin role via RequireAdmin middleware. +// Returns all registered users. The gateway enforces admin (operator) access +// via its centralized access-control policy (the /api/admin prefix is Admin-only). func (h *RESTHandler) ListUsers(c *gin.Context) { start := time.Now() @@ -313,7 +314,8 @@ func (h *RESTHandler) ListUsers(c *gin.Context) { // AssumeIdentity handles POST /api/auth/assume. // Generates a new JWT for the target user with the assumedBy claim. -// The gateway enforces admin role via AdminRouteGuard middleware. +// The gateway enforces admin (operator) access via its centralized +// access-control policy (POST /api/auth/assume is Admin-only). func (h *RESTHandler) AssumeIdentity(c *gin.Context) { start := time.Now() From ce5026c41b8549dce8b81e14be53b561c6f08953 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:45:31 +0100 Subject: [PATCH 41/70] docs(cm): make 001 backup example copy-paste-safe - prepend mkdir -p backups to the pg_dump example in steps.md Activity 2 so the command works before the backups dir exists --- change-management/001_admin-finance-cleanup/steps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/change-management/001_admin-finance-cleanup/steps.md b/change-management/001_admin-finance-cleanup/steps.md index b137281b..516494ca 100644 --- a/change-management/001_admin-finance-cleanup/steps.md +++ b/change-management/001_admin-finance-cleanup/steps.md @@ -39,7 +39,7 @@ immediately before the deletion so the state can be restored if needed. ## Checklist: 1. At `/opt/gofin`, dump the database, e.g.: - `docker compose exec -T postgresql pg_dump -U gofin -Fc gofin > backups/gofin-pre-001-$(date +%Y%m%d%H%M%S).dump` + `mkdir -p backups && docker compose exec -T postgresql pg_dump -U gofin -Fc gofin > backups/gofin-pre-001-$(date +%Y%m%d%H%M%S).dump` 2. Record the backup artifact path and size. ## Rollback Plan: From 2ed1097ef76acfce90b577e75cf44ebcc685f0e8 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:55:11 +0100 Subject: [PATCH 42/70] docs(auth): describe operator-only admin and centralized access control --- docs/auth.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/auth.md b/docs/auth.md index 6d70ea38..4ae2f916 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -37,19 +37,34 @@ exp : Expiration timestamp | Role | Permissions | |------|-------------| -| `user` | Full access to own financial data. No access to other users' data, admin panel, or Grafana. | -| `admin` | Everything `user` has, plus: user list, identity assumption, Grafana access. Admins have their own finance data and go through the same onboarding/budget flows as regular users. | +| `user` | Full access to own financial data. No access to other users' data, the admin panel, or Grafana. | +| `admin` | Operator-only identity. Scope is authentication, the admin panel (user list), identity assumption, user deletion, and Grafana access. An admin owns no personal finance data and does not go through the onboarding or budget flows; the personal finance APIs return 403 to a direct admin. | ### Enforcement Points | Layer | Mechanism | |-------|-----------| -| API Gateway | Validates JWT via Auth Service gRPC. Rejects invalid/expired with 401. Checks admin role for `/api/admin/*` routes (403 if not admin). Passes `X-User-ID` and `X-User-Role` to downstream services. | +| API Gateway | Applies one centralized `AccessControl` middleware backed by an ordered policy table. Validates the JWT via Auth Service gRPC (401 on missing/invalid/expired), resolves each route to one of four access levels (Public / Authenticated / Personal / Admin), and enforces the role that level requires (403 on mismatch). Strips client-supplied identity headers, then passes `X-User-ID`, `X-User-Role`, and (when assuming) `X-Assumed-By` to downstream services. | | Auth Service | Validates signature, expiration, and revocation status. Checks `tokens_revoked_at` on the user record: tokens with `iat` before this timestamp are rejected (handles password change invalidation). | | Downstream Services | Trust the gateway. Scope all queries to the `user_id` from gateway headers. | | Shell App | Client-side route guards as defense in depth (not sole enforcement). Hides admin routes for non-admin users. | | Grafana Auth Proxy | Validates JWT locally using the shared signing secret. Checks `role === 'admin'`. Proxies to Grafana with `X-WEBAUTH-USER` header. | +### Gateway Access Levels + +The gateway classifies every route into one of four access levels via a single ordered policy table (`services/gateway/internal/access`). A route is resolved by exact match first, then the longest matching prefix, else the fail-safe default of `Authenticated`: + +| Level | Meaning | Token required | Role check | +|-------|---------|----------------|------------| +| `Public` | Reachable with no token | No | None | +| `Authenticated` | Any valid token | Yes | None | +| `Personal` | Valid token acting as a regular user | Yes | `role == "user"` | +| `Admin` | Valid token acting as an operator | Yes | `role == "admin"` | + +The `Personal` routes are `/api/finance/*`, `/api/expenses/*`, `/api/datarights/exports*`, and `POST /api/auth/onboarding-complete`. A direct admin (`role=admin`) receives 403 on these routes; an assumed session carries `role=user` (plus an `assumedBy` claim), so it satisfies `Personal` and passes unchanged. `POST /api/auth/restore` is `Authenticated`, so an assumed session can always restore regardless of role. + +This single `AccessControl` middleware replaced the former trio of mechanisms, all now removed: the `unauthenticatedRoutes` allowlist, `RequireAdmin`, and `AdminRouteGuard` (with its `adminOnlyRoutes`/`adminOnlyPrefixes`). + ## Auth Flows ### Registration From cc77f874bb933c26f18b548208ae54c118d423b2 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:55:23 +0100 Subject: [PATCH 43/70] docs(data-model): note admin owns no finance data and link 001 cleanup --- docs/data-model.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/data-model.md b/docs/data-model.md index 5f1fc0e7..4e85029a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -17,6 +17,8 @@ PostgreSQL runs as a single instance with separate schemas and connection creden Canonical source: `services/datarights/db/migrations/` +> **Operator-only admin:** data export is a `Personal` operation, so `datarights.export_jobs` holds only regular-user rows and never admin-owned rows. The one-time cleanup (linked under Finance Schema) also removed any admin-owned export jobs. + ### `datarights.export_jobs` Stores export job metadata. The service tracks job lifecycle but does not persist user data: collected data exists only transiently during ZIP assembly. Key design points: @@ -59,7 +61,7 @@ Canonical source: `services/auth/db/migrations/` Stores user accounts with credentials and profile data. Key design points: - `password_hash`: bcrypt-hashed password -- `role`: supports `'user'` and `'admin'` (checked via RBAC at every layer) +- `role`: supports `'user'` and `'admin'` (checked via RBAC at every layer). `admin` is an operator-only identity and owns no finance data (see the Finance Schema note below). - `currency`: the user's display currency, returned by `GET /api/auth/me` for frontend formatting - `has_completed_onboarding`: gates the onboarding redirect flow - `tokens_revoked_at`: set on password change; any token with `iat` before this timestamp is rejected, forcing re-login on all other sessions @@ -72,6 +74,8 @@ Tracks revoked refresh tokens by their `jti` claim. Entries include the token's Canonical source: `services/finance/db/migrations/` +> **Operator-only admin:** `admin` accounts own no rows in any finance table. Admin is an operator identity (authentication, admin panel, identity assumption, user deletion, Grafana) and never goes through the onboarding or budget flows. A one-time change-managed cleanup removed any pre-existing admin-owned finance rows; see [`change-management/001_admin-finance-cleanup`](../change-management/001_admin-finance-cleanup/description.md). Every table below is scoped per `user_id` and only ever holds regular-user (`role=user`) data. + ### `finance.budget_periods` One row per user per calendar month. Stores the budget amount and E/D/S percentage split. Constrained so percentages always sum to 100 and each user has at most one period per month. From f895028937d47a9c0c5c839a6445d3ad43977a79 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:55:45 +0100 Subject: [PATCH 44/70] docs(api): annotate route access levels and policy resolution --- docs/api.md | 67 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/api.md b/docs/api.md index 06718fc0..ced4600b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,23 +15,62 @@ Canonical sources for endpoint definitions: ## Gateway Routing -| URL Prefix | Downstream Service | Auth Required | -|------------|-------------------|---------------| -| `/api/auth/*` | Auth Service | Varies (registration, login, and refresh are public) | -| `/api/expenses/*` | Expense Service | Yes | -| `/api/finance/*` | Finance Service | Yes | -| `/api/datarights/*` | Datarights Service | Yes | -| `/api/admin/*` | Auth Service | Yes (admin only) | +The gateway applies one centralized access-control policy: every route resolves to exactly one of four access levels, enforced by the single `AccessControl` middleware. Resolution is exact match first, then the longest matching path prefix, else the fail-safe default of `Authenticated`. + +| Level | Meaning | Token required | Role check | +|-------|---------|----------------|------------| +| `Public` | Reachable with no token | No | None | +| `Authenticated` | Any valid token | Yes | None | +| `Personal` | Valid token acting as a regular user | Yes | `role == "user"` | +| `Admin` | Valid token acting as an operator | Yes | `role == "admin"` | + +### Route Groups + +| URL Prefix | Downstream Service | Access Level | +|------------|-------------------|--------------| +| `/api/auth/*` | Auth Service | Mixed (see route-level table): Public login/register/refresh, Personal onboarding-complete, Admin assume, Authenticated for the rest | +| `/api/expenses/*` | Expense Service | Personal | +| `/api/finance/*` | Finance Service | Personal | +| `/api/datarights/*` | Datarights Service | Mixed: `exports*` is Personal, `deletions*` is Admin | +| `/api/admin/*` | Auth Service | Admin | + +### Route-Level Access + +The canonical policy table (`services/gateway/internal/access/policy.go`) classifies each route: + +| Access | Method | Path | Match | +|--------|--------|------|-------| +| `Public` | POST | `/api/auth/register` | Exact | +| `Public` | POST | `/api/auth/login` | Exact | +| `Public` | POST | `/api/auth/refresh` | Exact | +| `Public` | GET | `/health` | Exact | +| `Public` | GET | `/metrics` | Exact | +| `Admin` | (any) | `/api/admin` | Prefix | +| `Admin` | POST | `/api/auth/assume` | Exact | +| `Admin` | (any) | `/api/datarights/deletions` | Prefix | +| `Personal` | POST | `/api/auth/onboarding-complete` | Exact | +| `Personal` | (any) | `/api/finance` | Prefix | +| `Personal` | (any) | `/api/expenses` | Prefix | +| `Personal` | (any) | `/api/datarights/exports` | Prefix | +| `Authenticated` | (any) | `/api/auth/me` | Prefix | +| `Authenticated` | POST | `/api/auth/logout` | Exact | +| `Authenticated` | POST | `/api/auth/restore` | Exact | +| `Authenticated` (default) | — | *(unmatched, e.g. bare `/api/auth`)* | Fallback | + +The `Personal` routes are `/api/finance/*`, `/api/expenses/*`, `/api/datarights/exports*`, and `POST /api/auth/onboarding-complete`. A direct admin (`role=admin`) receives **403** on all of them; an assumed session carries `role=user` (with an `assumedBy` claim) and passes. `POST /api/auth/restore` is `Authenticated`, so an assumed session can always restore. ### Auth Middleware Behavior -For every incoming request (except public routes): +A single `AccessControl` middleware gates every request. For each one it: -1. Extract the `gofin_access` cookie -2. Call Auth Service gRPC `ValidateToken` -3. On success: set `X-User-ID` and `X-User-Role` headers, forward to downstream service -4. On 401: return 401 to the frontend (frontend handles refresh) -5. Admin-only routes: additionally verify `X-User-Role == admin`, return 403 if not +1. Strips client-supplied identity headers (`X-User-ID`, `X-User-Role`, `X-Assumed-By`) so they can never be spoofed +2. Resolves the route's access level from the policy table: exact match first, then the longest matching prefix, else the default of `Authenticated` +3. `Public` routes short-circuit here with no token read +4. Otherwise extracts the `gofin_access` cookie and calls Auth Service gRPC `ValidateToken` (401 on a missing cookie or validation failure; the frontend then handles refresh) +5. Sets `X-User-ID` and `X-User-Role` (and `X-Assumed-By` when the session is assumed) for the downstream service +6. Enforces the level's role: `Authenticated` passes any valid token; `Personal` requires `role == "user"`; `Admin` requires `role == "admin"`. A role mismatch returns 403 + +Because `Personal` requires `role == "user"`, a direct admin is refused (403) on the personal finance APIs, while an assumed `role=user` session passes. ## Endpoint Groups @@ -142,7 +181,7 @@ All API errors follow a consistent shape: |-------------|---------|-------------------| | 400 | Validation failure | Missing fields, E/D/S percentages not summing to 100%, weak password | | 401 | Authentication failure | Invalid credentials, expired token, invalid token | -| 403 | Authorization failure | Non-admin accessing admin routes, correcting an expense outside the current period | +| 403 | Authorization failure | Non-admin accessing admin routes, direct admin accessing personal finance routes, correcting an expense outside the current period | | 404 | Resource not found | No budget period for the requested month | | 409 | Conflict | Duplicate email/username, expense already corrected, tag in use | | 429 | Rate limited | Data export requested within 30-day cooldown | From fcc8d6307d4bd2cd9025505e1b2579e6ba61c30b Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:55:59 +0100 Subject: [PATCH 45/70] docs(architecture): describe centralized access control and operator-only admin --- docs/architecture.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 43da6eb9..533511fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,18 +78,19 @@ The shell owns: - **Routing**: the complete route tree; remotes export page components, not routed applications - **Auth Context**: a Zustand store shared across all remotes via Module Federation's shared scope - **Layout**: persistent navbar, admin assumption banner, mobile navigation -- **Auth Guards**: route protection (unauthenticated, authenticated, admin-only, onboarding) +- **Auth Guards**: route protection (unauthenticated, authenticated, onboarding, and a direct-admin guard that redirects operators off personal finance routes to `/admin`) ### API Gateway (Node 2) -A lightweight Go/Gin reverse proxy that validates auth and routes requests: +A lightweight Go/Gin reverse proxy that validates auth and routes requests. A single centralized `AccessControl` middleware backed by an ordered policy table classifies every route into one of four access levels (Public / Authenticated / Personal / Admin) and enforces it: -1. Extracts the `gofin_access` cookie from every request -2. Calls Auth Service gRPC `ValidateToken` to verify the JWT -3. On success: injects `X-User-ID` and `X-User-Role` headers, forwards to the downstream service -4. On failure: returns 401 (expired/invalid token) or 403 (insufficient role) +1. Strips client-supplied identity headers so they cannot be spoofed +2. Resolves the route's access level from the policy table (exact match first, then longest prefix, else the fail-safe default of `Authenticated`) +3. `Public` routes pass with no token read (e.g. `/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/health`, `/metrics`) +4. Otherwise verifies the `gofin_access` cookie via Auth Service gRPC `ValidateToken` (401 on failure) and injects `X-User-ID`, `X-User-Role`, and (when assuming) `X-Assumed-By` +5. Enforces the level's role: `Personal` routes require `role == "user"` and `Admin` routes require `role == "admin"` (403 on mismatch) -Unauthenticated exceptions: `/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`. +This one middleware replaced the former `unauthenticatedRoutes` allowlist, `RequireAdmin`, and `AdminRouteGuard`. Because the personal finance routes are `Personal`, a direct admin is refused there while an assumed regular-user session passes. ### Auth Service (Node 2) @@ -99,7 +100,7 @@ Owns user identity, credentials, and token lifecycle: - JWT access/refresh token generation and validation - Refresh token rotation with blacklist-based revocation - Password change with bulk token invalidation (`tokens_revoked_at` timestamp) -- RBAC enforcement (user/admin roles) +- RBAC enforcement (user/admin roles; `admin` is operator-only and owns no finance data) - Admin identity assumption and restoration ### Expense Service (Node 2) From c0e785b13b77f3990cfb5b8cae4030438c979e70 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:56:08 +0100 Subject: [PATCH 46/70] docs(readme): describe operator-only admin in RBAC blurb --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2942c33e..0c1cb928 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,14 @@ https://github.com/user-attachments/assets/44ed82d6-a7b6-499c-90ba-3655cedaf110 ## Introduction -gofin is an intentionally overengineered personal finance tracker that lets users set monthly budgets with an essentials/desires/savings split, log expenses, and track spending via a real-time dashboard. It serves a dual purpose: a functional personal finance tool and a learning platform for distributed systems patterns. Key features include an immutable expense ledger backed by [immudb](https://immudb.io/) with bank-style corrections (no edits, only appends), pro-rata expense spreading across multiple months, GDPR-compliant data export with email delivery, RBAC with admin identity assumption, and a full observability stack with Prometheus and Grafana. +gofin is an intentionally overengineered personal finance tracker that lets users set monthly budgets with an essentials/desires/savings split, log expenses, and track spending via a real-time dashboard. It serves a dual purpose: a functional personal finance tool and a learning platform for distributed systems patterns. Key features include an immutable expense ledger backed by [immudb](https://immudb.io/) with bank-style corrections (no edits, only appends), pro-rata expense spreading across multiple months, GDPR-compliant data export with email delivery, role-based access control with an operator-only admin identity (used for operations and identity assumption, never personal finance), and a full observability stack with Prometheus and Grafana. ## Technology Stack - **Frontend:** React micro-frontends composed at runtime via Module Federation 2.0, with a Node.js SSR shell app - **Backend:** Go microservices (Gin framework) communicating over REST and gRPC - **Databases:** PostgreSQL (relational data), immudb (immutable expense ledger) -- **Auth:** JWT with RBAC, Google OAuth, admin identity assumption +- **Auth:** JWT with RBAC (operator-only admin), Google OAuth, admin identity assumption - **Observability:** Prometheus, Grafana, Alertmanager - **Infrastructure:** Docker Compose, Cloudflare Tunnels, single-VPS deployment - **CI/CD:** GitHub Actions with automated deployment on push to main From 098a642df1034e95d37139255de0b700571943fb Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:56:21 +0100 Subject: [PATCH 47/70] docs(development): correct mock-mode admin-uses-finance instruction --- docs/development.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development.md b/docs/development.md index 2e4ad106..7a49f5a6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -61,15 +61,15 @@ This starts the shell dev server with the `VITE_MOCK_API=true` flag, which activ The mock layer provides: -- An authenticated admin user (auto-logged in) +- An operator (admin) user, auto-logged in by default (lands on `/admin`; personal finance routes are not reachable as a direct admin) +- A regular user (`alex`) available for identity assumption and for exercising the finance UI - A current-month budget period ($3,000, 50/30/20 split) - Seven sample expenses across different categories and tags - All eleven default tags plus one custom tag - Dashboard aggregation data (summary, pacing, tag spending, cumulative chart, historical comparison) - An upcoming pro-rata installment -- A second user in the admin panel for identity assumption testing -Mock data is defined in `frontend/apps/shell/mocks/data.ts`. Request handlers are in `frontend/apps/shell/mocks/handlers.ts`. To change the authenticated user (e.g., test as a non-admin), edit the `currentMockUser` export in `data.ts`. +Mock data is defined in `frontend/apps/shell/mocks/data.ts`. Request handlers are in `frontend/apps/shell/mocks/handlers.ts`. The default mock user is the operator (admin), who lands on `/admin` and sees only the admin panel plus Settings (Profile and Password); a direct admin is redirected off the personal finance routes, so the budget/expenses/dashboard fixtures are not reachable while acting as the admin. To exercise the personal finance UI, set `currentMockUser` to the regular user (`regularUser`, "alex") in `data.ts`, or log in as the admin and assume that user from the admin panel. **How it works:** From 6ec612648104c91e18fb6f18e6d65384ba6b9f2b Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:56:40 +0100 Subject: [PATCH 48/70] docs(testing): reflect access-control, role-helper, and settings test targets --- docs/testing.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index a4282b35..f77fde50 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -104,22 +104,25 @@ func TestExpenseRepository_Integration(t *testing.T) { | Finance: pro-rata scheduling | High | Installment math, remainder handling, year rollover, application at period creation | | Auth: token lifecycle | High | Generation, validation, refresh rotation, blacklisting, `tokens_revoked_at` | | Auth: RBAC | High | Role checking, identity assumption, audit claims | -| Gateway: auth middleware | High | Valid token pass-through, expired token rejection | +| Gateway: access policy resolver | High | `resolve` precedence (exact > longest prefix > default), every access level, per-route classification | +| Gateway: `AccessControl` middleware | High | Per-level/per-role outcomes (pass/401/403), direct admin vs assumed `role=user` on Personal routes, header stripping, `X-Assumed-By` forwarding | | Finance: aggregations | Medium | Category sums, tag spending, cumulative spend | | Auth: password handling | Medium | Hashing, verification, strength validation | -| Gateway: routing | Medium | Route matching, header forwarding | +| Gateway: route coverage | Medium | Every known route prefix resolves to an explicit (non-default) access level | ### Frontend | Component | Priority | Focus | |-----------|----------|-------| | Auth store | High | Login, logout, refresh, assumption state transitions | -| Auth flow (login/register) | High | Form validation, error messages, redirect | +| Role helpers (`core/roles.ts`) | High | `canUseFinanceFeatures`, `canUseAdminFeatures`, `getLandingPath` truth table across both roles | +| Auth flow (login/register) | High | Form validation, error messages, role-based landing (`getLandingPath`) | +| Shell nav + route guards | High | Role-derived nav, direct-admin finance-route guard (redirect to `/admin`), assumed-user nav + Return to Admin | | ExpenseForm | High | Validation, pro-rata toggle, submission | | ExpenseLog (TanStack Table) | Medium | Sorting, filtering, pagination | | Dashboard gauges | Medium | Percentage display, color coding | | Onboarding flow | Medium | Step progression, skip behavior | -| Settings page | Medium | E/D/S sum validation, tag CRUD | +| Settings composition | Medium | Role-derived tabs (admin: Profile+Password; user: Budget/Profile/Password/Tags), default tab = first, finance sections absent for admin | | Admin panel | Medium | User list, assume action, role gating | ## Frontend Testing Patterns From ccaec6902e2456824af1e92f0b3648977c46d4b1 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 21:56:56 +0100 Subject: [PATCH 49/70] docs(api): replace em dash with (any) in policy table per repo convention --- docs/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index ced4600b..13d60cea 100644 --- a/docs/api.md +++ b/docs/api.md @@ -55,7 +55,7 @@ The canonical policy table (`services/gateway/internal/access/policy.go`) classi | `Authenticated` | (any) | `/api/auth/me` | Prefix | | `Authenticated` | POST | `/api/auth/logout` | Exact | | `Authenticated` | POST | `/api/auth/restore` | Exact | -| `Authenticated` (default) | — | *(unmatched, e.g. bare `/api/auth`)* | Fallback | +| `Authenticated` (default) | (any) | *(unmatched, e.g. bare `/api/auth`)* | Fallback | The `Personal` routes are `/api/finance/*`, `/api/expenses/*`, `/api/datarights/exports*`, and `POST /api/auth/onboarding-complete`. A direct admin (`role=admin`) receives **403** on all of them; an assumed session carries `role=user` (with an `assumedBy` claim) and passes. `POST /api/auth/restore` is `Authenticated`, so an assumed session can always restore. From f1d70c08641a5057ca6aac5820dc15f50c224921 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 22:05:08 +0100 Subject: [PATCH 50/70] docs(development): correct admin bootstrap to operator-only onboarding --- docs/development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development.md b/docs/development.md index 7a49f5a6..2267e04e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -169,9 +169,9 @@ The first admin user must be created before the admin panel, identity assumption just seed-admin ``` -This runs the auth service's `seed-admin` CLI subcommand, which reads `ADMIN_USERNAME`, `ADMIN_EMAIL`, and `ADMIN_PASSWORD` from environment variables. The command is idempotent: if an admin already exists, it exits successfully. +This runs the auth service's `seed-admin` CLI subcommand, which reads `ADMIN_USERNAME`, `ADMIN_EMAIL`, and `ADMIN_PASSWORD` from environment variables. The command is idempotent: if an admin already exists, it exits successfully. It also marks the admin's onboarding as complete server-side, so the operator does not go through the finance onboarding flow. -The admin then logs in through the normal UI and completes onboarding like any other user. +The admin is an operator-only identity: it owns no finance data and does not use the onboarding or budget flows (`POST /api/auth/onboarding-complete` is a `Personal` route and returns 403 to a direct admin). After logging in through the normal UI, a direct admin lands on `/admin` and is redirected off the personal finance routes. ## Datarights Service From 999c178807049ec71f2c318db92e34e8ddb24ac7 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sat, 4 Jul 2026 22:17:29 +0100 Subject: [PATCH 51/70] docs(cm): document validator exit code 2 in skill - 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. --- .pi/skills/change-management/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.pi/skills/change-management/SKILL.md b/.pi/skills/change-management/SKILL.md index eec09959..68341a25 100644 --- a/.pi/skills/change-management/SKILL.md +++ b/.pi/skills/change-management/SKILL.md @@ -185,7 +185,9 @@ python change-management/.validation/validate_change_management.py --root change ``` - Exit `0`: all validated items conform. -- Exit `1`: a violation; it prints the item, file, and failing rule. +- Exit `1`: at least one validated item has a violation; each is printed to + stderr (item, file, failing rule). +- Exit `2`: a usage error, e.g. the `--root` or `--item` path does not exist. Run it before opening either PR (item creation and completion). CI runs the same command on every push/PR via the `validate-change-management` job in From ca37e4c7c68c3199b241c23cb33860f29af7f933 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 00:50:15 +0100 Subject: [PATCH 52/70] fix(gateway): match prefixes on segment boundary - 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 --- services/gateway/internal/access/resolver.go | 23 ++++++++- .../gateway/internal/access/resolver_test.go | 49 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/services/gateway/internal/access/resolver.go b/services/gateway/internal/access/resolver.go index 37e4163c..0783e772 100644 --- a/services/gateway/internal/access/resolver.go +++ b/services/gateway/internal/access/resolver.go @@ -8,7 +8,8 @@ import "strings" // 1. Exact match: a Match==Exact rule whose Method matches (or is "") and // whose Path equals path. // 2. Longest prefix: among Match==Prefix rules whose Method matches (or is "") -// and where path starts with the rule Path, the one with the longest Path. +// and where path is within the rule Path's segment (see hasPathPrefix), +// the one with the longest Path. // 3. Default: Policy.Default (Authenticated, the fail-safe). // // resolve is a pure function with no gin or net/http dependency, so the whole @@ -26,7 +27,7 @@ func (p Policy) resolve(method, path string) Access { if rule.Match != Prefix || !methodMatches(rule.Method, method) { continue } - if strings.HasPrefix(path, rule.Path) && len(rule.Path) > bestLen { + if hasPathPrefix(path, rule.Path) && len(rule.Path) > bestLen { bestLen = len(rule.Path) level = rule.Access } @@ -39,3 +40,21 @@ func (p Policy) resolve(method, path string) Access { func methodMatches(ruleMethod, requestMethod string) bool { return ruleMethod == "" || ruleMethod == requestMethod } + +// hasPathPrefix reports whether path lies within prefix's path segment: prefix +// must match on a segment boundary, not merely as a leading substring. So +// "/api/finance" matches "/api/finance" and "/api/finance/periods" but NOT +// "/api/finance-summary". Without this boundary a future sibling such as +// "/api/datarights/exports-admin" would match the Personal prefix +// "/api/datarights/exports" and be under-restricted; since this resolver is the +// single authz gate, that would be a direct authz bug. +// +// Policy prefixes are segment paths with no trailing slash (see policy.go), so +// the boundary is the character immediately after the prefix: either the end +// of the path or a '/'. +func hasPathPrefix(path, prefix string) bool { + if !strings.HasPrefix(path, prefix) { + return false + } + return len(path) == len(prefix) || path[len(prefix)] == '/' +} diff --git a/services/gateway/internal/access/resolver_test.go b/services/gateway/internal/access/resolver_test.go index f4a852f2..92eb26ff 100644 --- a/services/gateway/internal/access/resolver_test.go +++ b/services/gateway/internal/access/resolver_test.go @@ -55,6 +55,16 @@ func TestResolve_DefaultPolicy(t *testing.T) { {"bare datarights falls to default", "GET", "/api/datarights", Authenticated}, {"unknown datarights subpath falls to default", "GET", "/api/datarights/unknown", Authenticated}, {"unknown api path falls to default", "GET", "/api/unknown", Authenticated}, + + // --- Segment-boundary: a prefix rule must match on a segment boundary, + // not as a leading substring. These sibling paths share a prefix rule's + // text but not its segment, so they fall through to the fail-safe + // Default rather than borrowing the sibling's level. --- + {"finance-summary is not the finance group", "GET", "/api/finance-summary", Authenticated}, + {"expenses-report is not the expenses group", "GET", "/api/expenses-report", Authenticated}, + {"admin-tools is not the admin group", "GET", "/api/admin-tools", Authenticated}, + {"exports-admin does not borrow the Personal exports prefix", "POST", "/api/datarights/exports-admin", Authenticated}, + {"deletions-log does not borrow the Admin deletions prefix", "DELETE", "/api/datarights/deletions-log", Authenticated}, } for _, tc := range cases { @@ -109,6 +119,45 @@ func TestResolve_LongestPrefixWins(t *testing.T) { } } +// TestResolve_PrefixRequiresSegmentBoundary proves prefix rules match only on a +// path-segment boundary, never as a leading substring. This is the property +// that keeps a sibling path from silently borrowing a neighbor's access level. +// The datarights case is the concrete footgun: without the boundary, +// "/api/datarights/exports-admin" would match the Personal prefix +// "/api/datarights/exports" and be under-restricted, even though this resolver +// is the single authz gate. +func TestResolve_PrefixRequiresSegmentBoundary(t *testing.T) { + policy := DefaultPolicy() + + cases := []struct { + name string + method string + path string + want Access + }{ + // Within the segment: the prefix rule applies. + {"bare group matches", "GET", "/api/finance", Personal}, + {"subpath matches", "GET", "/api/finance/periods", Personal}, + {"exports subpath matches", "GET", "/api/datarights/exports/job-1", Personal}, + {"deletions subpath matches", "DELETE", "/api/datarights/deletions/abc", Admin}, + + // Substring, not a segment: the prefix rule must NOT apply, so the path + // falls through to the fail-safe Default. + {"finance sibling does not match", "GET", "/api/finance-summary", Authenticated}, + {"admin sibling does not match", "GET", "/api/admin-tools", Authenticated}, + {"personal exports sibling does not match", "POST", "/api/datarights/exports-admin", Authenticated}, + {"admin deletions sibling does not match", "DELETE", "/api/datarights/deletions-log", Authenticated}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := policy.resolve(tc.method, tc.path); got != tc.want { + t.Errorf("resolve(%q, %q) = %s, want %s", tc.method, tc.path, got, tc.want) + } + }) + } +} + // TestResolve_DefaultFallback confirms an empty policy (and any unmatched path) // returns the configured Default. func TestResolve_DefaultFallback(t *testing.T) { From 4a2d3ef0eeb54f3b2dcd8d676b094ea38fef2187 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 00:50:18 +0100 Subject: [PATCH 53/70] docs(gateway): clarify 403 code contract - 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 --- services/gateway/internal/access/control.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/gateway/internal/access/control.go b/services/gateway/internal/access/control.go index 74f3bce0..ea7f686e 100644 --- a/services/gateway/internal/access/control.go +++ b/services/gateway/internal/access/control.go @@ -127,8 +127,10 @@ func abortUnauthorized(c *gin.Context, message string) { }) } -// rejectForbidden ends the request with the unchanged 403 contract, preserving -// the role-denied warn log formerly emitted by middleware.rejectNonAdmin. +// rejectForbidden ends the request with the unchanged 403 code contract (the +// machine-readable "FORBIDDEN" code is preserved; the human-facing message text +// was consolidated to a generic "Access denied"), preserving the role-denied +// warn log formerly emitted by middleware.rejectNonAdmin. func rejectForbidden(c *gin.Context, logger *slog.Logger, result *TokenValidationResult) { logger.Warn("access denied", slog.String("method", c.Request.Method), From 00d4dcdc7318d429cdf3b87f7ee7bf55ffdfab4c Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 00:50:20 +0100 Subject: [PATCH 54/70] refactor(shell): rename shadowed onSuccess param - 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 --- frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts b/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts index 96e29162..bb96e4a4 100644 --- a/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts +++ b/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts @@ -55,15 +55,15 @@ export function useLoginForm(): { state: LoginFormState; actions: LoginFormActio ); const mutation = useFormMutation<Awaited<ReturnType<typeof login>>>({ - onSuccess: (user) => { + onSuccess: (loggedInUser) => { const returnTo = consumeReturnToPath(); - if (!user.hasCompletedOnboarding) { + if (!loggedInUser.hasCompletedOnboarding) { navigate("/onboarding"); } else if (returnTo && returnTo !== "/login" && returnTo !== "/register") { navigate(returnTo); } else { - navigate(getLandingPath(user)); + navigate(getLandingPath(loggedInUser)); } }, }); From 090af764b9fd4528113596feacfcd98332a7a6ca Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 00:50:23 +0100 Subject: [PATCH 55/70] test(shell): lock admin returnTo precedence - 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) --- .../features/auth/__tests__/login.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx b/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx index b2d19f9e..8dbf5497 100644 --- a/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx +++ b/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx @@ -43,6 +43,7 @@ async function renderLoginPage(options?: { route?: string; searchParams?: Record { path: "/admin", element: <div>Admin page</div> }, { path: "/onboarding", element: <div>Onboarding page</div> }, { path: "/expenses", element: <div>Expenses page</div> }, + { path: "/settings", element: <div>Settings page</div> }, ], }); } @@ -194,6 +195,35 @@ describe("login page", () => { expect(screen.getByText("Expenses page")).toBeInTheDocument(); }); }); + + it("honors a non-finance returnTo for an admin instead of the /admin landing path", async () => { + // Regression guard: after login, both the onSuccess handler (returnTo) and + // the getLandingPath effect (/admin for admins) can fire. onSuccess must + // win so the admin lands on their saved path, not the landing path. + // /settings is not a FINANCE_ROUTE, so the auth-layout guard would not + // otherwise correct an override back to the intended destination. + const admin = buildUser({ role: "admin", hasCompletedOnboarding: true }); + setupUnauthenticatedMock({ + "/api/auth/login": { user: admin }, + }); + + vi.spyOn(Storage.prototype, "getItem").mockImplementation((key: string) => { + if (key === "gofin_return_to") return "/settings"; + return null; + }); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => {}); + + await renderLoginPage(); + + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "admin@example.com" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + + await waitFor(() => { + expect(screen.getByText("Settings page")).toBeInTheDocument(); + }); + expect(screen.queryByText("Admin page")).not.toBeInTheDocument(); + }); }); describe("already-authenticated redirect", () => { From 15ea2de9f0d4bdfc1fdbe4b73bdaf8f534805a20 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 00:50:24 +0100 Subject: [PATCH 56/70] fix(settings): fall back to first tab - 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 --- .../src/features/settings/SettingsFeature.tsx | 12 +++-- .../__tests__/SettingsFeature-admin.test.tsx | 47 ++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/frontend/apps/finance/src/features/settings/SettingsFeature.tsx b/frontend/apps/finance/src/features/settings/SettingsFeature.tsx index c7d8d6ff..86f0cce5 100644 --- a/frontend/apps/finance/src/features/settings/SettingsFeature.tsx +++ b/frontend/apps/finance/src/features/settings/SettingsFeature.tsx @@ -16,7 +16,13 @@ export function SettingsFeature({ user, onUserUpdated }: SettingsPageProps) { }, []); const sectionProps = { user, onUserUpdated }; - const activeDefinition = tabList.find((tab) => tab.id === activeTab); + // Resolve to a concrete tab. activeTab is seeded from tabList[0] and the role + // is stable per mount, but the auth store can re-render this component with a + // new user (via onUserUpdated -> checkAuth) without remounting. If the role + // ever flips, a persisted activeTab could fall outside the new tabList; fall + // back to the first tab so the desktop view always renders a valid section. + const activeDefinition = + tabList.find((tab) => tab.id === activeTab) ?? tabList[0]; return ( <div className="space-y-4"> @@ -51,9 +57,9 @@ export function SettingsFeature({ user, onUserUpdated }: SettingsPageProps) { <Card className="flex-1"> <CardHeader> - <CardTitle>{activeDefinition?.label}</CardTitle> + <CardTitle>{activeDefinition.label}</CardTitle> </CardHeader> - <CardContent>{activeDefinition?.render(sectionProps)}</CardContent> + <CardContent>{activeDefinition.render(sectionProps)}</CardContent> </Card> </div> diff --git a/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx b/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx index cbdf3e74..5146152a 100644 --- a/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx +++ b/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx @@ -1,9 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { SettingsFeature } from "@/features/settings"; import { buildUser } from "@gofin/test-utils"; +const regularUser = buildUser({ + role: "user", + username: "alice", + email: "alice@example.com", +}); + const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -78,4 +84,43 @@ describe("SettingsFeature - admin composition", () => { // the admin render path, so no request is made. expect(mockFetch).not.toHaveBeenCalled(); }); + + it("falls back to the first tab when activeTab is not in the current tab list", async () => { + // A default fetch response keeps the regular-user sections (which fetch on + // mount) quiet during the initial render. + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ defaults: null, tags: [], data: [], total: 0 }), + }); + + // Start as a regular user: activeTab defaults to "budget" (userTabs[0]). + const { rerender } = render( + <MemoryRouter> + <SettingsFeature user={regularUser} onUserUpdated={vi.fn()} /> + </MemoryRouter>, + ); + // Let the regular-user sections settle their on-mount fetches before the + // role flip so no state update lands on an unmounted section. + await waitFor(() => + expect(screen.getAllByLabelText("Monthly Budget").length).toBeGreaterThanOrEqual(1), + ); + + // Role flips to admin mid-session on the SAME component instance (this is + // what the auth store does via onUserUpdated -> checkAuth: it re-renders + // with a new user, it does not remount). adminTabs = [profile, password] + // no longer contains "budget", so the persisted activeTab diverges. + rerender( + <MemoryRouter> + <SettingsFeature user={adminUser} onUserUpdated={vi.fn()} /> + </MemoryRouter>, + ); + + // The fallback resolves activeDefinition to tabList[0] (Profile), so the + // desktop card renders the profile form instead of an empty card. With the + // old optional chaining this would render nothing. + const usernameInputs = screen.getAllByLabelText("Username") as HTMLInputElement[]; + expect(usernameInputs.length).toBeGreaterThanOrEqual(1); + expect(usernameInputs[0].value).toBe("operator"); + }); }); From 85ef95f1f09b15fb9cce6fd5a95f4114f8a3fb97 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 02:11:43 +0100 Subject: [PATCH 57/70] feat(access): add shared route registry and resolver module - 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 --- services/access/access.go | 43 +++++++++ services/access/go.mod | 3 + services/access/registry.go | 111 ++++++++++++++++++++++ services/access/registry_test.go | 94 ++++++++++++++++++ services/access/resolver.go | 100 ++++++++++++++++++++ services/access/resolver_test.go | 157 +++++++++++++++++++++++++++++++ services/go.work | 1 + 7 files changed, 509 insertions(+) create mode 100644 services/access/access.go create mode 100644 services/access/go.mod create mode 100644 services/access/registry.go create mode 100644 services/access/registry_test.go create mode 100644 services/access/resolver.go create mode 100644 services/access/resolver_test.go diff --git a/services/access/access.go b/services/access/access.go new file mode 100644 index 00000000..c6d35d1e --- /dev/null +++ b/services/access/access.go @@ -0,0 +1,43 @@ +// Package access is the single source of truth for GoFin's gateway-facing +// route surface and its access model. It is deliberately free of gin and +// net/http server code so both the gateway (which derives its authz policy +// from the Registry) and every downstream service (which registers its routes +// from the Registry) can import it without a build dependency on each other. +// +// The package owns three things: +// - the Access classification applied to every route (access.go), +// - the Registry of concrete routes and their access levels (registry.go), +// - a gin-priority path resolver used by the gateway middleware (resolver.go). +package access + +// Access is the classification applied to every gateway-facing route. It +// determines whether a request needs a token and, if so, which role may +// proceed. +type Access int + +const ( + // Public routes are reachable with no token. + Public Access = iota + // Authenticated routes require any valid token, regardless of role. + Authenticated + // Personal routes require a valid token acting as a regular user (role == "user"). + Personal + // Admin routes require a valid token acting as an operator (role == "admin"). + Admin +) + +// String returns the level name, used for readable logs and test diagnostics. +func (a Access) String() string { + switch a { + case Public: + return "Public" + case Authenticated: + return "Authenticated" + case Personal: + return "Personal" + case Admin: + return "Admin" + default: + return "Unknown" + } +} diff --git a/services/access/go.mod b/services/access/go.mod new file mode 100644 index 00000000..5a432586 --- /dev/null +++ b/services/access/go.mod @@ -0,0 +1,3 @@ +module github.com/ItsThompson/gofin/services/access + +go 1.26 diff --git a/services/access/registry.go b/services/access/registry.go new file mode 100644 index 00000000..526cbd7e --- /dev/null +++ b/services/access/registry.go @@ -0,0 +1,111 @@ +package access + +import "net/http" + +// Route is one concrete gateway-facing endpoint. Every route carries a +// mandatory Access, so classifying a route is inseparable from declaring it: +// a service cannot register a route (bind-by-ID from RoutesFor) without the +// Registry entry that also states who may reach it. +type Route struct { + // ID is a stable, unique key (e.g. "auth.login", "finance.periods.update"). + // Services bind their handlers to routes by this ID. + ID string + // Service is the downstream service that registers the route + // ("auth" | "finance" | "expense" | "datarights"). /api/admin/users is + // registered by auth, so its Service is "auth" even though its Access is Admin. + Service string + // Method is the HTTP method (http.MethodGet, http.MethodPost, ...). + Method string + // Path is the full gin path pattern including params, e.g. + // "/api/finance/tags/:id" or "/api/expenses/:id/history". + Path string + // Access is the classification enforced by the gateway middleware. + Access Access +} + +// Registry is the single, exhaustive list of every gateway-facing downstream +// route. It is the sole source of truth: downstream services register their +// routes by iterating RoutesFor, and the gateway classifies each request by +// resolving against these patterns (see resolver.go). +// +// Gateway-native endpoints (/health, /metrics) are intentionally absent: no +// downstream service serves them, so the gateway classifies them itself. +// +// Path strings must match gin's registered patterns byte-for-byte (verified by +// each service's registration coverage test against engine.Routes()). +var Registry = []Route{ + // --- auth service --- + {ID: "auth.register", Service: "auth", Method: http.MethodPost, Path: "/api/auth/register", Access: Public}, + {ID: "auth.login", Service: "auth", Method: http.MethodPost, Path: "/api/auth/login", Access: Public}, + {ID: "auth.refresh", Service: "auth", Method: http.MethodPost, Path: "/api/auth/refresh", Access: Public}, + {ID: "auth.logout", Service: "auth", Method: http.MethodPost, Path: "/api/auth/logout", Access: Authenticated}, + {ID: "auth.me.get", Service: "auth", Method: http.MethodGet, Path: "/api/auth/me", Access: Authenticated}, + {ID: "auth.me.update", Service: "auth", Method: http.MethodPut, Path: "/api/auth/me", Access: Authenticated}, + {ID: "auth.me.password", Service: "auth", Method: http.MethodPost, Path: "/api/auth/me/password", Access: Authenticated}, + {ID: "auth.onboarding_complete", Service: "auth", Method: http.MethodPost, Path: "/api/auth/onboarding-complete", Access: Personal}, + {ID: "auth.assume", Service: "auth", Method: http.MethodPost, Path: "/api/auth/assume", Access: Admin}, + {ID: "auth.restore", Service: "auth", Method: http.MethodPost, Path: "/api/auth/restore", Access: Authenticated}, + {ID: "admin.users.list", Service: "auth", Method: http.MethodGet, Path: "/api/admin/users", Access: Admin}, + + // --- finance service --- + {ID: "finance.onboarding", Service: "finance", Method: http.MethodPost, Path: "/api/finance/onboarding", Access: Personal}, + {ID: "finance.defaults.get", Service: "finance", Method: http.MethodGet, Path: "/api/finance/defaults", Access: Personal}, + {ID: "finance.defaults.update", Service: "finance", Method: http.MethodPut, Path: "/api/finance/defaults", Access: Personal}, + {ID: "finance.periods.current", Service: "finance", Method: http.MethodGet, Path: "/api/finance/periods/current", Access: Personal}, + {ID: "finance.periods.list", Service: "finance", Method: http.MethodGet, Path: "/api/finance/periods", Access: Personal}, + {ID: "finance.periods.create", Service: "finance", Method: http.MethodPost, Path: "/api/finance/periods", Access: Personal}, + {ID: "finance.periods.update", Service: "finance", Method: http.MethodPut, Path: "/api/finance/periods/:id", Access: Personal}, + {ID: "finance.tags.list", Service: "finance", Method: http.MethodGet, Path: "/api/finance/tags", Access: Personal}, + {ID: "finance.tags.create", Service: "finance", Method: http.MethodPost, Path: "/api/finance/tags", Access: Personal}, + {ID: "finance.tags.update", Service: "finance", Method: http.MethodPut, Path: "/api/finance/tags/:id", Access: Personal}, + {ID: "finance.tags.delete", Service: "finance", Method: http.MethodDelete, Path: "/api/finance/tags/:id", Access: Personal}, + {ID: "finance.summary", Service: "finance", Method: http.MethodGet, Path: "/api/finance/summary", Access: Personal}, + {ID: "finance.spending.by_tag", Service: "finance", Method: http.MethodGet, Path: "/api/finance/spending/by-tag", Access: Personal}, + {ID: "finance.spending.cumulative", Service: "finance", Method: http.MethodGet, Path: "/api/finance/spending/cumulative", Access: Personal}, + {ID: "finance.spending.comparison", Service: "finance", Method: http.MethodGet, Path: "/api/finance/spending/comparison", Access: Personal}, + {ID: "finance.spending.trends", Service: "finance", Method: http.MethodGet, Path: "/api/finance/spending/trends", Access: Personal}, + {ID: "finance.prorata.create", Service: "finance", Method: http.MethodPost, Path: "/api/finance/prorata", Access: Personal}, + {ID: "finance.prorata.upcoming", Service: "finance", Method: http.MethodGet, Path: "/api/finance/prorata/upcoming", Access: Personal}, + + // --- expense service --- + {ID: "expense.create", Service: "expense", Method: http.MethodPost, Path: "/api/expenses", Access: Personal}, + {ID: "expense.list", Service: "expense", Method: http.MethodGet, Path: "/api/expenses", Access: Personal}, + {ID: "expense.suggestions", Service: "expense", Method: http.MethodGet, Path: "/api/expenses/suggestions", Access: Personal}, + {ID: "expense.prorata.group", Service: "expense", Method: http.MethodGet, Path: "/api/expenses/prorata/:groupId", Access: Personal}, + {ID: "expense.get", Service: "expense", Method: http.MethodGet, Path: "/api/expenses/:id", Access: Personal}, + {ID: "expense.correct", Service: "expense", Method: http.MethodPost, Path: "/api/expenses/:id/correct", Access: Personal}, + {ID: "expense.history", Service: "expense", Method: http.MethodGet, Path: "/api/expenses/:id/history", Access: Personal}, + + // --- datarights service --- + {ID: "datarights.exports.create", Service: "datarights", Method: http.MethodPost, Path: "/api/datarights/exports", Access: Personal}, + {ID: "datarights.exports.list", Service: "datarights", Method: http.MethodGet, Path: "/api/datarights/exports", Access: Personal}, + {ID: "datarights.exports.get", Service: "datarights", Method: http.MethodGet, Path: "/api/datarights/exports/:id", Access: Personal}, + {ID: "datarights.deletions.create", Service: "datarights", Method: http.MethodPost, Path: "/api/datarights/deletions", Access: Admin}, + {ID: "datarights.deletions.get", Service: "datarights", Method: http.MethodGet, Path: "/api/datarights/deletions/:id", Access: Admin}, +} + +// RoutesFor returns every Registry route registered by the named service, in +// Registry order. Downstream services iterate this to bind handlers by ID, so +// a route missing from the Registry can never be served. +func RoutesFor(service string) []Route { + var routes []Route + for _, r := range Registry { + if r.Service == service { + routes = append(routes, r) + } + } + return routes +} + +// Classifies reports whether method+path is matched by an explicit Registry +// entry, as opposed to falling through to the fail-safe Authenticated default +// in Resolve. Services use it as a defense-in-depth check that every route they +// register is classified by the Registry. +func Classifies(method, path string) bool { + for _, r := range Registry { + if r.Method == method && matchPattern(r.Path, path) { + return true + } + } + return false +} diff --git a/services/access/registry_test.go b/services/access/registry_test.go new file mode 100644 index 00000000..9d398939 --- /dev/null +++ b/services/access/registry_test.go @@ -0,0 +1,94 @@ +package access + +import "testing" + +// TestRegistry_IDsAreUnique guards the bind-by-ID contract: services look up +// handlers by Route.ID, so a duplicate ID would silently shadow a handler. +func TestRegistry_IDsAreUnique(t *testing.T) { + seen := make(map[string]Route, len(Registry)) + for _, r := range Registry { + if prev, dup := seen[r.ID]; dup { + t.Errorf("duplicate route ID %q: %s %s and %s %s", r.ID, prev.Method, prev.Path, r.Method, r.Path) + } + seen[r.ID] = r + } +} + +// TestRegistry_EveryEntryResolvesToItsAccess derives the acceptance assertion +// directly from the Registry (no second hand-list): every entry's own +// method+path must resolve to the Access it declares. +func TestRegistry_EveryEntryResolvesToItsAccess(t *testing.T) { + for _, r := range Registry { + if got := Resolve(r.Method, r.Path); got != r.Access { + t.Errorf("Resolve(%q, %q) = %s, want %s (route %s)", r.Method, r.Path, got, r.Access, r.ID) + } + } +} + +// TestRegistry_EveryEntryIsClassified confirms Classifies agrees with the +// Registry for every real route: each entry's path is matched by an explicit +// entry rather than the fail-safe default. +func TestRegistry_EveryEntryIsClassified(t *testing.T) { + for _, r := range Registry { + if !Classifies(r.Method, r.Path) { + t.Errorf("Classifies(%q, %q) = false, want true (route %s)", r.Method, r.Path, r.ID) + } + } +} + +// TestRoutesFor_PartitionsRegistry proves RoutesFor slices the Registry cleanly: +// the four known services together account for every entry with no gaps or +// duplicates, and each returned route actually belongs to the requested service. +func TestRoutesFor_PartitionsRegistry(t *testing.T) { + services := []string{"auth", "finance", "expense", "datarights"} + + total := 0 + for _, svc := range services { + routes := RoutesFor(svc) + for _, r := range routes { + if r.Service != svc { + t.Errorf("RoutesFor(%q) returned route %s with Service %q", svc, r.ID, r.Service) + } + } + total += len(routes) + } + + if total != len(Registry) { + t.Errorf("RoutesFor over %v covered %d routes, but Registry has %d; a service is misnamed or missing", services, total, len(Registry)) + } +} + +// TestRoutesFor_UnknownServiceIsEmpty confirms an unknown service yields no +// routes rather than panicking or leaking cross-service entries. +func TestRoutesFor_UnknownServiceIsEmpty(t *testing.T) { + if routes := RoutesFor("nope"); len(routes) != 0 { + t.Errorf("RoutesFor(%q) = %d routes, want 0", "nope", len(routes)) + } +} + +// TestClassifies_UnknownRoutesAreUnclassified is the failure-mode guardrail: a +// route with no Registry entry, or a real path under the wrong method, is not +// classified, so Resolve returns the fail-safe Authenticated default. +func TestClassifies_UnknownRoutesAreUnclassified(t *testing.T) { + cases := []struct { + name string + method string + path string + }{ + {"unknown service", "GET", "/api/newservice/records"}, + {"wrong method on a real route", "GET", "/api/auth/login"}, + {"sibling substring of a real route", "POST", "/api/datarights/exports-admin"}, + {"bare group with no exact route", "GET", "/api/finance"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if Classifies(tc.method, tc.path) { + t.Errorf("Classifies(%q, %q) = true, want false", tc.method, tc.path) + } + if got := Resolve(tc.method, tc.path); got != Authenticated { + t.Errorf("Resolve(%q, %q) = %s, want Authenticated (fail-safe default)", tc.method, tc.path, got) + } + }) + } +} diff --git a/services/access/resolver.go b/services/access/resolver.go new file mode 100644 index 00000000..d4cc64e1 --- /dev/null +++ b/services/access/resolver.go @@ -0,0 +1,100 @@ +package access + +import "strings" + +// Resolve returns the access level the gateway must enforce for a concrete +// request method+path. It matches the path against every Registry pattern of +// the same method and, when several match, picks the one gin itself would +// dispatch to (see moreSpecific), so the gateway classifies exactly the route +// that will handle the request. +// +// Unmatched requests fall back to Authenticated, the fail-safe default: an +// unclassified path still requires a valid token, and (since it corresponds to +// no real route) 404s downstream. Because every Registry pattern is a concrete +// route with exact static segments, a sibling path like +// "/api/datarights/exports-admin" cannot borrow "/api/datarights/exports"'s +// level: static segments must match byte-for-byte. This makes the +// leading-substring bug that segment-boundary prefix matching guarded against +// (commit ca37e4c) impossible by construction. +func Resolve(method, path string) Access { + var best *Route + for i := range Registry { + entry := &Registry[i] + if entry.Method != method || !matchPattern(entry.Path, path) { + continue + } + if best == nil || moreSpecific(entry.Path, best.Path) { + best = entry + } + } + if best == nil { + return Authenticated + } + return best.Access +} + +// matchPattern reports whether a concrete path matches a gin route pattern. +// A static segment must equal the path segment exactly; a ":name" param +// matches exactly one non-empty segment; a "*name" catch-all matches the +// remainder of the path. Segment counts must line up unless a catch-all +// absorbs the rest. +func matchPattern(pattern, path string) bool { + patternSegments := strings.Split(pattern, "/") + pathSegments := strings.Split(path, "/") + + for i, seg := range patternSegments { + if strings.HasPrefix(seg, "*") { + // Catch-all absorbs this segment and everything after it. gin + // requires at least the boundary slash, i.e. one more segment. + return len(pathSegments) > i + } + if i >= len(pathSegments) { + return false + } + if strings.HasPrefix(seg, ":") { + if pathSegments[i] == "" { + return false // a param must match a non-empty segment + } + continue + } + if seg != pathSegments[i] { + return false + } + } + return len(pathSegments) == len(patternSegments) +} + +// moreSpecific reports whether pattern a outranks pattern b under gin's routing +// priority: comparing segment classes left to right, static (2) beats param +// (1) beats catch-all (0), and the first differing segment decides. This +// mirrors how gin picks a handler when patterns overlap, so the gateway +// classifies the same route gin dispatches to. The concrete overlap it settles +// is GET /api/expenses/suggestions (static) vs /api/expenses/:id (param). +func moreSpecific(a, b string) bool { + aSegments := strings.Split(a, "/") + bSegments := strings.Split(b, "/") + + shared := min(len(aSegments), len(bSegments)) + for i := range shared { + aClass := segmentClass(aSegments[i]) + bClass := segmentClass(bSegments[i]) + if aClass != bClass { + return aClass > bClass + } + } + // Equal classes across the shared prefix: the longer pattern is more + // specific (it constrains more segments). + return len(aSegments) > len(bSegments) +} + +// segmentClass scores a pattern segment by gin priority: static > param > catch-all. +func segmentClass(seg string) int { + switch { + case strings.HasPrefix(seg, "*"): + return 0 + case strings.HasPrefix(seg, ":"): + return 1 + default: + return 2 + } +} diff --git a/services/access/resolver_test.go b/services/access/resolver_test.go new file mode 100644 index 00000000..eb891a42 --- /dev/null +++ b/services/access/resolver_test.go @@ -0,0 +1,157 @@ +package access + +import "testing" + +// TestResolve_WorkedExamples ports the acceptance-criteria worked examples from +// the former gateway resolver suite to the Registry-backed pattern resolver. +// Each is a real route, so the outcome must be preserved exactly. +func TestResolve_WorkedExamples(t *testing.T) { + cases := []struct { + name string + method string + path string + want Access + }{ + {"login is public", "POST", "/api/auth/login", Public}, + {"register is public", "POST", "/api/auth/register", Public}, + {"refresh is public", "POST", "/api/auth/refresh", Public}, + {"me get is authenticated", "GET", "/api/auth/me", Authenticated}, + {"me update is authenticated", "PUT", "/api/auth/me", Authenticated}, + {"me password is authenticated", "POST", "/api/auth/me/password", Authenticated}, + {"logout is authenticated", "POST", "/api/auth/logout", Authenticated}, + {"restore is authenticated", "POST", "/api/auth/restore", Authenticated}, + {"onboarding-complete is personal", "POST", "/api/auth/onboarding-complete", Personal}, + {"assume is admin", "POST", "/api/auth/assume", Admin}, + {"admin users is admin", "GET", "/api/admin/users", Admin}, + {"finance periods is personal", "GET", "/api/finance/periods", Personal}, + {"finance period update is personal (param)", "PUT", "/api/finance/periods/abc-123", Personal}, + {"finance tag delete is personal (param)", "DELETE", "/api/finance/tags/tag-9", Personal}, + {"exports create is personal", "POST", "/api/datarights/exports", Personal}, + {"export by id is personal (param)", "GET", "/api/datarights/exports/export-456", Personal}, + {"deletions create is admin", "POST", "/api/datarights/deletions", Admin}, + {"deletion by id is admin (param)", "GET", "/api/datarights/deletions/abc-123", Admin}, + {"expense by id is personal (param)", "GET", "/api/expenses/e-1", Personal}, + {"expense history is personal (param)", "GET", "/api/expenses/e-1/history", Personal}, + {"expense prorata group is personal (param)", "GET", "/api/expenses/prorata/g-1", Personal}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Resolve(tc.method, tc.path); got != tc.want { + t.Errorf("Resolve(%q, %q) = %s, want %s", tc.method, tc.path, got, tc.want) + } + }) + } +} + +// TestResolve_FallsBackToAuthenticated covers every way a request misses the +// Registry: wrong method, sibling substrings that must not borrow a neighbor's +// level, bare groups with no exact route, and wholly unknown paths. +func TestResolve_FallsBackToAuthenticated(t *testing.T) { + cases := []struct { + name string + method string + path string + }{ + {"wrong method on a public exact", "GET", "/api/auth/login"}, + {"wrong method on an admin exact", "GET", "/api/auth/assume"}, + {"finance sibling substring", "GET", "/api/finance-summary"}, + {"expenses sibling substring", "GET", "/api/expenses-report"}, + {"admin sibling substring", "GET", "/api/admin-tools"}, + {"exports sibling substring", "POST", "/api/datarights/exports-admin"}, + {"deletions sibling substring", "DELETE", "/api/datarights/deletions-log"}, + {"bare finance group", "GET", "/api/finance"}, + {"bare admin group", "GET", "/api/admin"}, + {"bare datarights group", "GET", "/api/datarights"}, + {"unknown api path", "GET", "/api/unknown"}, + {"extra trailing segment", "GET", "/api/finance/tags/abc/extra"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Resolve(tc.method, tc.path); got != Authenticated { + t.Errorf("Resolve(%q, %q) = %s, want Authenticated", tc.method, tc.path, got) + } + }) + } +} + +// TestResolve_GinPriorityStaticBeatsParam pins the one real same-method overlap: +// GET /api/expenses/suggestions (static) vs GET /api/expenses/:id (param). Both +// patterns match the concrete "suggestions" path, and the resolver must pick +// the static route gin dispatches to. +func TestResolve_GinPriorityStaticBeatsParam(t *testing.T) { + const static = "/api/expenses/suggestions" + const param = "/api/expenses/:id" + + if !matchPattern(static, "/api/expenses/suggestions") { + t.Fatal("static pattern should match its own concrete path") + } + if !matchPattern(param, "/api/expenses/suggestions") { + t.Fatal("param pattern should also match /api/expenses/suggestions (the overlap under test)") + } + if !moreSpecific(static, param) { + t.Errorf("moreSpecific(%q, %q) = false, want true (static must outrank param)", static, param) + } + if moreSpecific(param, static) { + t.Errorf("moreSpecific(%q, %q) = true, want false (param must not outrank static)", param, static) + } +} + +// TestMatchPattern covers the segment matcher's contract independently of the +// Registry: static equality, single-segment params, catch-all remainder, and +// segment-count mismatches. +func TestMatchPattern(t *testing.T) { + cases := []struct { + name string + pattern string + path string + want bool + }{ + {"static equal", "/api/finance/tags", "/api/finance/tags", true}, + {"static unequal", "/api/finance/tags", "/api/finance/defaults", false}, + {"static substring is not a match", "/api/datarights/exports", "/api/datarights/exports-admin", false}, + {"param matches one segment", "/api/finance/tags/:id", "/api/finance/tags/abc", true}, + {"param rejects empty segment", "/api/finance/tags/:id", "/api/finance/tags/", false}, + {"param rejects extra segment", "/api/finance/tags/:id", "/api/finance/tags/abc/def", false}, + {"param rejects missing segment", "/api/finance/tags/:id", "/api/finance/tags", false}, + {"nested param", "/api/expenses/:id/history", "/api/expenses/e-1/history", true}, + {"nested param wrong tail", "/api/expenses/:id/history", "/api/expenses/e-1/correct", false}, + {"catch-all absorbs remainder", "/api/x/*rest", "/api/x/a/b/c", true}, + {"catch-all requires boundary", "/api/x/*rest", "/api/x", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := matchPattern(tc.pattern, tc.path); got != tc.want { + t.Errorf("matchPattern(%q, %q) = %v, want %v", tc.pattern, tc.path, got, tc.want) + } + }) + } +} + +// TestMoreSpecific proves the gin-priority ordering used to break overlaps: +// static > param > catch-all, decided at the first differing segment. +func TestMoreSpecific(t *testing.T) { + cases := []struct { + name string + a string + b string + want bool + }{ + {"static beats param", "/api/expenses/suggestions", "/api/expenses/:id", true}, + {"param beats catch-all", "/api/x/:id", "/api/x/*rest", true}, + {"static beats catch-all", "/api/x/y", "/api/x/*rest", true}, + {"param does not beat static", "/api/expenses/:id", "/api/expenses/suggestions", false}, + {"first differing segment decides", "/api/:a/static", "/api/static/:b", false}, + {"longer pattern wins on tie", "/api/x/:id/history", "/api/x/:id", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := moreSpecific(tc.a, tc.b); got != tc.want { + t.Errorf("moreSpecific(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + } +} diff --git a/services/go.work b/services/go.work index ca8a7eb7..e0304c1a 100644 --- a/services/go.work +++ b/services/go.work @@ -1,6 +1,7 @@ go 1.26 use ( + ./access ./auth ./datarights ./dbmigrate From 24ff27edf9462c3deb61dbcf30d2c11b8b49d449 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 02:16:35 +0100 Subject: [PATCH 58/70] refactor(gateway): derive access policy from shared registry - 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) --- services/gateway/Dockerfile | 4 +- services/gateway/go.mod | 3 + services/gateway/internal/access/access.go | 68 ------- services/gateway/internal/access/control.go | 33 +++- .../gateway/internal/access/control_test.go | 49 ++++- .../gateway/internal/access/coverage_test.go | 138 ------------- services/gateway/internal/access/policy.go | 45 ----- services/gateway/internal/access/resolver.go | 60 ------ .../gateway/internal/access/resolver_test.go | 186 ------------------ services/gateway/internal/router/router.go | 12 +- .../gateway/internal/router/router_test.go | 5 +- 11 files changed, 83 insertions(+), 520 deletions(-) delete mode 100644 services/gateway/internal/access/access.go delete mode 100644 services/gateway/internal/access/coverage_test.go delete mode 100644 services/gateway/internal/access/policy.go delete mode 100644 services/gateway/internal/access/resolver.go delete mode 100644 services/gateway/internal/access/resolver_test.go diff --git a/services/gateway/Dockerfile b/services/gateway/Dockerfile index 1be4af8a..e22e909e 100644 --- a/services/gateway/Dockerfile +++ b/services/gateway/Dockerfile @@ -8,14 +8,16 @@ WORKDIR /app # Copy module files for dependency caching. COPY gateway/go.mod gateway/go.sum* ./gateway/ +COPY access/go.mod access/go.sum* ./access/ COPY auth/go.mod auth/go.sum* ./auth/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ RUN --mount=type=cache,target=/go/pkg/mod \ cd gateway && GOWORK=off go mod download -# Copy only what the gateway build needs: gateway source + auth proto + healthcheck + metrics. +# Copy only what the gateway build needs: gateway source + access + auth proto + healthcheck + metrics. COPY gateway/ ./gateway/ +COPY access/ ./access/ COPY auth/proto/ ./auth/proto/ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ diff --git a/services/gateway/go.mod b/services/gateway/go.mod index ac457416..d0b7321c 100644 --- a/services/gateway/go.mod +++ b/services/gateway/go.mod @@ -6,6 +6,7 @@ require ( // The gateway imports auth/proto/authpb for the gRPC ValidateToken client. // Locally this resolves via go.work; in Docker builds (GOWORK=off) the // replace directive below points to the sibling module. + github.com/ItsThompson/gofin/services/access v0.0.0 github.com/ItsThompson/gofin/services/auth v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 @@ -16,6 +17,8 @@ require ( ) // Required for Docker builds where go.work is not available. +replace github.com/ItsThompson/gofin/services/access => ../access + replace github.com/ItsThompson/gofin/services/auth => ../auth replace github.com/ItsThompson/gofin/services/healthcheck => ../healthcheck diff --git a/services/gateway/internal/access/access.go b/services/gateway/internal/access/access.go deleted file mode 100644 index dfb07c2b..00000000 --- a/services/gateway/internal/access/access.go +++ /dev/null @@ -1,68 +0,0 @@ -// Package access owns the GoFin gateway access model as pure, gin-free code. -// -// It defines the access levels applied to every gateway route, the rule and -// policy types that encode who may reach what, and a pure resolver that maps a -// method+path to an access level. Keeping this package free of gin and -// net/http lets the entire access model be exhaustively table-tested without a -// running server. -package access - -// Access is the classification applied to every gateway route. It determines -// whether a request needs a token and, if so, which role may proceed. -type Access int - -const ( - // Public routes are reachable with no token. - Public Access = iota - // Authenticated routes require any valid token, regardless of role. - Authenticated - // Personal routes require a valid token acting as a regular user (role == "user"). - Personal - // Admin routes require a valid token acting as an operator (role == "admin"). - Admin -) - -// String returns the level name, used for readable logs and test diagnostics. -func (a Access) String() string { - switch a { - case Public: - return "Public" - case Authenticated: - return "Authenticated" - case Personal: - return "Personal" - case Admin: - return "Admin" - default: - return "Unknown" - } -} - -// MatchType selects exact vs prefix matching for a Rule. -type MatchType int - -const ( - // Exact matches when the method and path are identical to the rule. - Exact MatchType = iota - // Prefix matches when the request path starts with the rule path. - Prefix -) - -// Rule maps a method+path pattern to an access level. -type Rule struct { - // Method is the HTTP method to match. "" means any method (only meaningful - // for Prefix rules and method-agnostic exacts). - Method string - // Path is the exact path (Exact) or the path prefix (Prefix) to match. - Path string - // Match selects exact vs prefix matching. - Match MatchType - // Access is the level granted when this rule matches. - Access Access -} - -// Policy is the ordered list of rules plus the fallback for unmatched paths. -type Policy struct { - rules []Rule - Default Access // Authenticated (fail-safe) -} diff --git a/services/gateway/internal/access/control.go b/services/gateway/internal/access/control.go index ea7f686e..6616c50d 100644 --- a/services/gateway/internal/access/control.go +++ b/services/gateway/internal/access/control.go @@ -5,6 +5,8 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/access" ) // Identity headers the gateway sets for downstream services after a successful @@ -30,9 +32,14 @@ const ( // AccessControl is the single gin middleware that enforces the gateway access // policy, replacing the former Auth + RequireAdmin + AdminRouteGuard trio. // +// It takes an injected resolve func rather than a policy value so the shared +// services/access module stays ignorant of gateway-native routes: the gateway +// composes GatewayResolve (health/metrics -> Public, else access.Resolve) and +// passes it in ("inject strategy, don't branch on context"). +// // For every request it: // 1. strips the spoofable identity headers, -// 2. resolves the route's access level from the policy, +// 2. resolves the route's access level via the injected resolver, // 3. short-circuits Public routes with no token read, // 4. otherwise validates the gofin_access cookie (401 on missing/invalid), // 5. injects the validated identity as downstream headers, and @@ -40,12 +47,12 @@ const ( // // The per-level switch is fail-safe: only Authenticated passes without a role // check, and any level that is not explicitly allowed is denied (403). -func AccessControl(validator TokenValidator, policy Policy, logger *slog.Logger) gin.HandlerFunc { +func AccessControl(validator TokenValidator, resolve func(method, path string) access.Access, logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { stripIdentityHeaders(c) - level := policy.resolve(c.Request.Method, c.Request.URL.Path) - if level == Public { + level := resolve(c.Request.Method, c.Request.URL.Path) + if level == access.Public { c.Next() return } @@ -74,17 +81,17 @@ func AccessControl(validator TokenValidator, policy Policy, logger *slog.Logger) setIdentityHeaders(c, result) switch level { - case Personal: + case access.Personal: if result.Role != roleUser { rejectForbidden(c, logger, result) return } - case Admin: + case access.Admin: if result.Role != roleAdmin { rejectForbidden(c, logger, result) return } - case Authenticated: + case access.Authenticated: // Any valid token passes; no role check. default: // Fail-safe by construction: Public is short-circuited before token @@ -98,6 +105,18 @@ func AccessControl(validator TokenValidator, policy Policy, logger *slog.Logger) } } +// GatewayResolve is the resolver the gateway injects into AccessControl. It +// classifies the two gateway-native endpoints (/health, /metrics) as Public +// and delegates every /api route to the shared registry resolver. Keeping this +// composition in the gateway is why services/access never needs to know about +// gateway-owned routes. +func GatewayResolve(method, path string) access.Access { + if path == "/health" || path == "/metrics" { + return access.Public + } + return access.Resolve(method, path) +} + // stripIdentityHeaders removes client-supplied identity headers before // resolution so they can never be spoofed. The gateway sets them only after a // successful validation (see setIdentityHeaders). diff --git a/services/gateway/internal/access/control_test.go b/services/gateway/internal/access/control_test.go index d547da64..41879df6 100644 --- a/services/gateway/internal/access/control_test.go +++ b/services/gateway/internal/access/control_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + sharedaccess "github.com/ItsThompson/gofin/services/access" "github.com/ItsThompson/gofin/services/gateway/internal/access" ) @@ -47,11 +48,12 @@ func captureLogger() (*slog.Logger, *bytesBuffer) { return logger, buf } -// buildEngine wires AccessControl (with the canonical DefaultPolicy) in front -// of a single handler registered for method+path. +// buildEngine wires AccessControl (with the gateway's GatewayResolve, which +// classifies every route via the shared services/access registry) in front of +// a single handler registered for method+path. func buildEngine(validator access.TokenValidator, logger *slog.Logger, method, path string, handler gin.HandlerFunc) *gin.Engine { engine := gin.New() - engine.Use(access.AccessControl(validator, access.DefaultPolicy(), logger)) + engine.Use(access.AccessControl(validator, access.GatewayResolve, logger)) engine.Handle(method, path, handler) return engine } @@ -356,14 +358,13 @@ func TestAccessControl_UnknownLevel_DeniesByDefault(t *testing.T) { result: &access.TokenValidationResult{UserID: "user-1", Role: "user"}, } - // A policy with no rules whose Default is an out-of-enum access level. - // resolve() returns this Default for every path, so a valid token reaches - // the middleware's switch with a level that matches none of the known - // cases and must fall through to the fail-safe deny. - policy := access.Policy{Default: access.Access(99)} + // A resolve func that returns an out-of-enum access level for every path. + // A valid token reaches the middleware's switch with a level that matches + // none of the known cases and must fall through to the fail-safe deny. + resolve := func(_, _ string) sharedaccess.Access { return sharedaccess.Access(99) } engine := gin.New() - engine.Use(access.AccessControl(validator, policy, silentLogger())) + engine.Use(access.AccessControl(validator, resolve, silentLogger())) engine.GET("/api/anything", okHandler) req := httptest.NewRequest(http.MethodGet, "/api/anything", nil) @@ -375,6 +376,36 @@ func TestAccessControl_UnknownLevel_DeniesByDefault(t *testing.T) { assert.Contains(t, rec.Body.String(), "FORBIDDEN") } +// --- Gateway-native classification: /health, /metrics, and unknown paths --- + +// TestGatewayResolve covers the gateway-owned classification that services/access +// intentionally does not know about: the gateway-native /health and /metrics +// endpoints are Public, while every other path is delegated to the shared +// registry resolver (a real route keeps its level; an unknown path falls to the +// fail-safe Authenticated default). +func TestGatewayResolve(t *testing.T) { + cases := []struct { + name string + method string + path string + want sharedaccess.Access + }{ + {"health is public", http.MethodGet, "/health", sharedaccess.Public}, + {"metrics is public", http.MethodGet, "/metrics", sharedaccess.Public}, + {"a real personal route keeps its level", http.MethodGet, "/api/finance/periods", sharedaccess.Personal}, + {"a real admin route keeps its level", http.MethodGet, "/api/admin/users", sharedaccess.Admin}, + {"an unknown path falls to authenticated", http.MethodGet, "/api/unknown", sharedaccess.Authenticated}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := access.GatewayResolve(tc.method, tc.path); got != tc.want { + t.Errorf("GatewayResolve(%q, %q) = %s, want %s", tc.method, tc.path, got, tc.want) + } + }) + } +} + // bytesBuffer is a minimal io.Writer that also parses the last JSON log line. type bytesBuffer struct { data []byte diff --git a/services/gateway/internal/access/coverage_test.go b/services/gateway/internal/access/coverage_test.go deleted file mode 100644 index 29dbf345..00000000 --- a/services/gateway/internal/access/coverage_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package access - -import "testing" - -// Route coverage guardrail. -// -// This file is a HAND-MAINTAINED enumeration of every known gateway route -// class. For each one it asserts two things: the route resolves to its intended -// access level, and that level comes from an explicit rule rather than the -// fail-safe Default (Authenticated). It turns "someone added a route but forgot -// to classify it" into a CI failure instead of an accidental exposure. -// -// It is intentionally separate from resolver_test.go: that suite proves -// precedence correctness (exact > longest prefix > default); this suite proves -// surface coverage (every real route is classified by a rule). -// -// Residual risk: because the enumeration is maintained by hand, it only guards -// routes someone remembered to list here. The whole-service prefixes -// (/api/finance, /api/expenses, /api/datarights/exports, /api/datarights/deletions, -// /api/admin) already cover every subpath beneath them, so new endpoints in -// those services are classified automatically. The real exposure is a NEW -// mixed-group route added under /api/auth or /api/datarights (the only groups -// that mix access levels): such a route must be added to BOTH the policy table -// (policy.go) and this enumeration. Forgetting the policy entry makes this test -// fail (the route resolves to the Default rather than its intended level); -// forgetting to also list it here is the residual gap this guardrail cannot -// close. - -// sentinelDefault is an Access value that no rule in DefaultPolicy() ever -// returns. Resolving a route under a policy whose Default is this sentinel lets -// the tests below tell "classified by a rule" apart from "fell through to the -// Default": a rule-classified route ignores Default and resolves identically -// under any Default, while an unclassified route returns whatever Default is -// configured. -const sentinelDefault Access = -1 - -// classifiedByRule reports whether method+path is matched by an explicit rule -// in DefaultPolicy(), as opposed to falling through to the policy Default. -// -// It resolves the route twice against policies that are identical except for -// their Default value. If a rule matches, both resolutions return that rule's -// level and agree; if no rule matches, each returns its own Default and they -// disagree. This distinguishes an explicit classification from the fail-safe -// fallback without changing the access package, which matters for routes whose -// intended level (Authenticated) happens to equal the real Default. -func classifiedByRule(method, path string) bool { - withRealDefault := DefaultPolicy() - withSentinelDefault := DefaultPolicy() - withSentinelDefault.Default = sentinelDefault - return withRealDefault.resolve(method, path) == withSentinelDefault.resolve(method, path) -} - -// TestCoverage_KnownRoutesResolveToExplicitLevel enumerates every known gateway -// route class and asserts each resolves to its intended, rule-classified access -// level. See the file header for the hand-maintained-enumeration caveat and its -// residual risk. -// -// For method-agnostic prefix classes (/api/admin, /api/datarights/*, -// /api/finance, /api/expenses) the method column carries a representative real -// method; classification is method-independent for those (proven in -// resolver_test.go), so any method resolves the same. -func TestCoverage_KnownRoutesResolveToExplicitLevel(t *testing.T) { - policy := DefaultPolicy() - - cases := []struct { - name string - method string - path string - want Access - }{ - // Public: reachable with no token. - {"register", "POST", "/api/auth/register", Public}, - {"login", "POST", "/api/auth/login", Public}, - {"refresh", "POST", "/api/auth/refresh", Public}, - {"health", "GET", "/health", Public}, - {"metrics", "GET", "/metrics", Public}, - - // Authenticated: any valid token, no role check. These live in the - // mixed /api/auth group, so their intended level equals the Default; - // the classifiedByRule assertion below is what proves each is - // rule-classified rather than a silent fall-through. - {"me (GET)", "GET", "/api/auth/me", Authenticated}, - {"me (PUT)", "PUT", "/api/auth/me", Authenticated}, - {"me password", "POST", "/api/auth/me/password", Authenticated}, - {"logout", "POST", "/api/auth/logout", Authenticated}, - {"restore", "POST", "/api/auth/restore", Authenticated}, - - // Admin: operator-only. - {"assume", "POST", "/api/auth/assume", Admin}, - {"admin group", "GET", "/api/admin", Admin}, - {"datarights deletions", "DELETE", "/api/datarights/deletions", Admin}, - - // Personal: valid token acting as a regular user. - {"onboarding-complete", "POST", "/api/auth/onboarding-complete", Personal}, - {"finance group", "GET", "/api/finance", Personal}, - {"expenses group", "GET", "/api/expenses", Personal}, - {"datarights exports", "POST", "/api/datarights/exports", Personal}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := policy.resolve(tc.method, tc.path); got != tc.want { - t.Errorf("resolve(%q, %q) = %s, want %s", tc.method, tc.path, got, tc.want) - } - if !classifiedByRule(tc.method, tc.path) { - t.Errorf("route %s %q is not classified by an explicit rule; it falls through to the policy Default (%s). Add a matching rule in policy.go.", tc.method, tc.path, policy.Default) - } - }) - } -} - -// TestCoverage_UnclassifiedRouteFallsThroughToDefault demonstrates the -// guardrail's failure mode, satisfying the acceptance criterion that adding a -// personal- or admin-intended route with no matching policy entry causes the -// coverage test to fail. -// -// A route with no rule resolves to the fail-safe Default (Authenticated) and is -// not classified by a rule. If such a route were added to the enumeration above -// with its intended non-default level (Personal or Admin), the want assertion -// would fail (got Authenticated), surfacing the missing classification in CI. -func TestCoverage_UnclassifiedRouteFallsThroughToDefault(t *testing.T) { - policy := DefaultPolicy() - - // A hypothetical new downstream route with no matching policy entry. - const method, path = "GET", "/api/newservice/records" - - if classifiedByRule(method, path) { - t.Fatalf("expected %q to be unclassified, but a rule matched it", path) - } - if got := policy.resolve(method, path); got != policy.Default { - t.Errorf("unclassified route should fall to Default %s, got %s", policy.Default, got) - } - // Because the Default is Authenticated, an intended-Personal or -Admin route - // left unclassified would fail the coverage table's want assertion. - if got := policy.resolve(method, path); got == Personal || got == Admin { - t.Errorf("unclassified route should not resolve to a role-gated level, got %s", got) - } -} diff --git a/services/gateway/internal/access/policy.go b/services/gateway/internal/access/policy.go deleted file mode 100644 index c19c61f4..00000000 --- a/services/gateway/internal/access/policy.go +++ /dev/null @@ -1,45 +0,0 @@ -package access - -// HTTP method constants used by the policy table. These are plain strings -// matching the values gin reports for c.Request.Method (identical to the -// net/http.Method* constants), kept local so the access package stays free of -// any net/http dependency and remains purely table-testable. -const ( - methodGet = "GET" - methodPost = "POST" - methodDelete = "DELETE" -) - -// DefaultPolicy returns the canonical GoFin access policy table. It is the -// single source of truth for who can reach what. Rule order is for -// readability only: the resolver is order-independent within a match class -// (see resolver.go), grouping rules here by access level to mirror the spec. -func DefaultPolicy() Policy { - return Policy{ - Default: Authenticated, // fail-safe: unmatched routes require any valid token - rules: []Rule{ - // Public: reachable with no token. - {Method: methodPost, Path: "/api/auth/register", Match: Exact, Access: Public}, - {Method: methodPost, Path: "/api/auth/login", Match: Exact, Access: Public}, - {Method: methodPost, Path: "/api/auth/refresh", Match: Exact, Access: Public}, - {Method: methodGet, Path: "/health", Match: Exact, Access: Public}, - {Method: methodGet, Path: "/metrics", Match: Exact, Access: Public}, - - // Admin: operator-only. - {Method: "", Path: "/api/admin", Match: Prefix, Access: Admin}, - {Method: methodPost, Path: "/api/auth/assume", Match: Exact, Access: Admin}, - {Method: "", Path: "/api/datarights/deletions", Match: Prefix, Access: Admin}, - - // Personal: valid token acting as a regular user (role == "user"). - {Method: methodPost, Path: "/api/auth/onboarding-complete", Match: Exact, Access: Personal}, - {Method: "", Path: "/api/finance", Match: Prefix, Access: Personal}, - {Method: "", Path: "/api/expenses", Match: Prefix, Access: Personal}, - {Method: "", Path: "/api/datarights/exports", Match: Prefix, Access: Personal}, - - // Authenticated: any valid token, no role check. - {Method: "", Path: "/api/auth/me", Match: Prefix, Access: Authenticated}, - {Method: methodPost, Path: "/api/auth/logout", Match: Exact, Access: Authenticated}, - {Method: methodPost, Path: "/api/auth/restore", Match: Exact, Access: Authenticated}, - }, - } -} diff --git a/services/gateway/internal/access/resolver.go b/services/gateway/internal/access/resolver.go deleted file mode 100644 index 0783e772..00000000 --- a/services/gateway/internal/access/resolver.go +++ /dev/null @@ -1,60 +0,0 @@ -package access - -import "strings" - -// resolve returns the access level for a method+path. -// -// Precedence: -// 1. Exact match: a Match==Exact rule whose Method matches (or is "") and -// whose Path equals path. -// 2. Longest prefix: among Match==Prefix rules whose Method matches (or is "") -// and where path is within the rule Path's segment (see hasPathPrefix), -// the one with the longest Path. -// 3. Default: Policy.Default (Authenticated, the fail-safe). -// -// resolve is a pure function with no gin or net/http dependency, so the whole -// access model can be exhaustively table-tested without a server. -func (p Policy) resolve(method, path string) Access { - for _, rule := range p.rules { - if rule.Match == Exact && methodMatches(rule.Method, method) && rule.Path == path { - return rule.Access - } - } - - bestLen := -1 - level := p.Default - for _, rule := range p.rules { - if rule.Match != Prefix || !methodMatches(rule.Method, method) { - continue - } - if hasPathPrefix(path, rule.Path) && len(rule.Path) > bestLen { - bestLen = len(rule.Path) - level = rule.Access - } - } - return level -} - -// methodMatches reports whether a rule's method constraint is satisfied by the -// request method. An empty rule method means "any method". -func methodMatches(ruleMethod, requestMethod string) bool { - return ruleMethod == "" || ruleMethod == requestMethod -} - -// hasPathPrefix reports whether path lies within prefix's path segment: prefix -// must match on a segment boundary, not merely as a leading substring. So -// "/api/finance" matches "/api/finance" and "/api/finance/periods" but NOT -// "/api/finance-summary". Without this boundary a future sibling such as -// "/api/datarights/exports-admin" would match the Personal prefix -// "/api/datarights/exports" and be under-restricted; since this resolver is the -// single authz gate, that would be a direct authz bug. -// -// Policy prefixes are segment paths with no trailing slash (see policy.go), so -// the boundary is the character immediately after the prefix: either the end -// of the path or a '/'. -func hasPathPrefix(path, prefix string) bool { - if !strings.HasPrefix(path, prefix) { - return false - } - return len(path) == len(prefix) || path[len(prefix)] == '/' -} diff --git a/services/gateway/internal/access/resolver_test.go b/services/gateway/internal/access/resolver_test.go deleted file mode 100644 index 92eb26ff..00000000 --- a/services/gateway/internal/access/resolver_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package access - -import "testing" - -// TestResolve_DefaultPolicy covers the section-4 worked examples plus -// exhaustive cases across every rule in the canonical policy table: Public -// exacts, method-agnostic prefixes, exact-over-default precedence, longest -// prefix ranking, and the Authenticated fail-safe default. -func TestResolve_DefaultPolicy(t *testing.T) { - policy := DefaultPolicy() - - cases := []struct { - name string - method string - path string - want Access - }{ - // --- Section-4 worked examples (acceptance criteria) --- - {"login is public", "POST", "/api/auth/login", Public}, - {"me is authenticated (prefix)", "GET", "/api/auth/me", Authenticated}, - {"me password is authenticated (prefix)", "POST", "/api/auth/me/password", Authenticated}, - {"onboarding-complete is personal (exact)", "POST", "/api/auth/onboarding-complete", Personal}, - {"assume is admin (exact)", "POST", "/api/auth/assume", Admin}, - {"restore is authenticated (exact)", "POST", "/api/auth/restore", Authenticated}, - {"finance periods is personal (prefix)", "GET", "/api/finance/periods", Personal}, - {"exports is personal (prefix)", "POST", "/api/datarights/exports", Personal}, - {"deletions by id is admin (longest prefix)", "DELETE", "/api/datarights/deletions/abc-123", Admin}, - {"admin users is admin (prefix)", "GET", "/api/admin/users", Admin}, - {"bare auth group falls to default", "GET", "/api/auth", Authenticated}, - - // --- Remaining Public exacts --- - {"register is public", "POST", "/api/auth/register", Public}, - {"refresh is public", "POST", "/api/auth/refresh", Public}, - {"health is public", "GET", "/health", Public}, - {"metrics is public", "GET", "/metrics", Public}, - - // --- Remaining Authenticated exacts --- - {"logout is authenticated (exact)", "POST", "/api/auth/logout", Authenticated}, - - // --- Personal whole-service prefixes --- - {"finance bare group is personal", "GET", "/api/finance", Personal}, - {"expenses is personal (prefix)", "POST", "/api/expenses", Personal}, - {"expenses subpath is personal", "GET", "/api/expenses/123", Personal}, - {"exports subpath is personal (longest prefix)", "GET", "/api/datarights/exports/export-456", Personal}, - - // --- Admin prefixes --- - {"admin bare group is admin", "GET", "/api/admin", Admin}, - {"deletions bare is admin (prefix)", "POST", "/api/datarights/deletions", Admin}, - - // --- Method sensitivity: an exact rule's method must match --- - {"wrong method on public exact falls to default", "GET", "/api/auth/login", Authenticated}, - {"wrong method on admin exact falls to default", "GET", "/api/auth/assume", Authenticated}, - - // --- Default fallback for unclassified paths --- - {"bare datarights falls to default", "GET", "/api/datarights", Authenticated}, - {"unknown datarights subpath falls to default", "GET", "/api/datarights/unknown", Authenticated}, - {"unknown api path falls to default", "GET", "/api/unknown", Authenticated}, - - // --- Segment-boundary: a prefix rule must match on a segment boundary, - // not as a leading substring. These sibling paths share a prefix rule's - // text but not its segment, so they fall through to the fail-safe - // Default rather than borrowing the sibling's level. --- - {"finance-summary is not the finance group", "GET", "/api/finance-summary", Authenticated}, - {"expenses-report is not the expenses group", "GET", "/api/expenses-report", Authenticated}, - {"admin-tools is not the admin group", "GET", "/api/admin-tools", Authenticated}, - {"exports-admin does not borrow the Personal exports prefix", "POST", "/api/datarights/exports-admin", Authenticated}, - {"deletions-log does not borrow the Admin deletions prefix", "DELETE", "/api/datarights/deletions-log", Authenticated}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := policy.resolve(tc.method, tc.path) - if got != tc.want { - t.Errorf("resolve(%q, %q) = %s, want %s", tc.method, tc.path, got, tc.want) - } - }) - } -} - -// TestResolve_ExactBeatsPrefix proves the exact pass runs before the prefix -// pass: even when a broader prefix rule would match the same path, the exact -// rule wins regardless of table order. -func TestResolve_ExactBeatsPrefix(t *testing.T) { - policy := Policy{ - Default: Authenticated, - rules: []Rule{ - {Method: "", Path: "/api/auth", Match: Prefix, Access: Personal}, - {Method: "POST", Path: "/api/auth/assume", Match: Exact, Access: Admin}, - }, - } - - if got := policy.resolve("POST", "/api/auth/assume"); got != Admin { - t.Errorf("exact rule should win over prefix: got %s, want %s", got, Admin) - } - // A sibling path with no exact rule falls to the prefix rule. - if got := policy.resolve("GET", "/api/auth/other"); got != Personal { - t.Errorf("prefix rule should apply when no exact matches: got %s, want %s", got, Personal) - } -} - -// TestResolve_LongestPrefixWins proves the resolver picks the longest matching -// prefix, independent of the order rules appear in the table. -func TestResolve_LongestPrefixWins(t *testing.T) { - // Shorter prefix listed AFTER the longer one to ensure ordering is not - // what selects the winner. - policy := Policy{ - Default: Authenticated, - rules: []Rule{ - {Method: "", Path: "/api/datarights/deletions", Match: Prefix, Access: Admin}, - {Method: "", Path: "/api/datarights", Match: Prefix, Access: Personal}, - }, - } - - if got := policy.resolve("DELETE", "/api/datarights/deletions/1"); got != Admin { - t.Errorf("longest prefix should win: got %s, want %s", got, Admin) - } - if got := policy.resolve("GET", "/api/datarights/other"); got != Personal { - t.Errorf("shorter prefix should apply when longer does not match: got %s, want %s", got, Personal) - } -} - -// TestResolve_PrefixRequiresSegmentBoundary proves prefix rules match only on a -// path-segment boundary, never as a leading substring. This is the property -// that keeps a sibling path from silently borrowing a neighbor's access level. -// The datarights case is the concrete footgun: without the boundary, -// "/api/datarights/exports-admin" would match the Personal prefix -// "/api/datarights/exports" and be under-restricted, even though this resolver -// is the single authz gate. -func TestResolve_PrefixRequiresSegmentBoundary(t *testing.T) { - policy := DefaultPolicy() - - cases := []struct { - name string - method string - path string - want Access - }{ - // Within the segment: the prefix rule applies. - {"bare group matches", "GET", "/api/finance", Personal}, - {"subpath matches", "GET", "/api/finance/periods", Personal}, - {"exports subpath matches", "GET", "/api/datarights/exports/job-1", Personal}, - {"deletions subpath matches", "DELETE", "/api/datarights/deletions/abc", Admin}, - - // Substring, not a segment: the prefix rule must NOT apply, so the path - // falls through to the fail-safe Default. - {"finance sibling does not match", "GET", "/api/finance-summary", Authenticated}, - {"admin sibling does not match", "GET", "/api/admin-tools", Authenticated}, - {"personal exports sibling does not match", "POST", "/api/datarights/exports-admin", Authenticated}, - {"admin deletions sibling does not match", "DELETE", "/api/datarights/deletions-log", Authenticated}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := policy.resolve(tc.method, tc.path); got != tc.want { - t.Errorf("resolve(%q, %q) = %s, want %s", tc.method, tc.path, got, tc.want) - } - }) - } -} - -// TestResolve_DefaultFallback confirms an empty policy (and any unmatched path) -// returns the configured Default. -func TestResolve_DefaultFallback(t *testing.T) { - policy := Policy{Default: Authenticated} - if got := policy.resolve("GET", "/anything"); got != Authenticated { - t.Errorf("empty policy should return Default: got %s, want %s", got, Authenticated) - } - - custom := Policy{Default: Public} - if got := custom.resolve("GET", "/anything"); got != Public { - t.Errorf("Default should be honored: got %s, want %s", got, Public) - } -} - -// TestResolve_MethodAgnosticPrefix confirms a prefix rule with an empty method -// matches every HTTP method, while a method-scoped exact only matches its -// method. -func TestResolve_MethodAgnosticPrefix(t *testing.T) { - policy := DefaultPolicy() - - for _, method := range []string{"GET", "POST", "PUT", "DELETE", "PATCH"} { - if got := policy.resolve(method, "/api/admin/users"); got != Admin { - t.Errorf("method-agnostic prefix should match %s: got %s, want %s", method, got, Admin) - } - } -} diff --git a/services/gateway/internal/router/router.go b/services/gateway/internal/router/router.go index 5739673e..4297995d 100644 --- a/services/gateway/internal/router/router.go +++ b/services/gateway/internal/router/router.go @@ -39,14 +39,16 @@ func New( engine.Use(metrics.HTTPMetrics()) engine.Use(middleware.RequestLogger(logger)) // AccessControl is the single global gate: it resolves each route against the - // canonical policy table and enforces Public/Authenticated/Personal/Admin, - // replacing the former per-request auth + per-group admin guards. - engine.Use(access.AccessControl(validator, access.DefaultPolicy(), logger)) + // shared services/access registry (via GatewayResolve, which also classifies + // the gateway-native /health and /metrics as Public) and enforces + // Public/Authenticated/Personal/Admin, replacing the former per-request auth + + // per-group admin guards. + engine.Use(access.AccessControl(validator, access.GatewayResolve, logger)) - // Prometheus metrics endpoint (Public in the policy table). + // Prometheus metrics endpoint (Public via GatewayResolve). metrics.Register(engine) - // Health check endpoint (Public in the policy table). + // Health check endpoint (Public via GatewayResolve). engine.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) diff --git a/services/gateway/internal/router/router_test.go b/services/gateway/internal/router/router_test.go index 9699bbc6..5d8d6864 100644 --- a/services/gateway/internal/router/router_test.go +++ b/services/gateway/internal/router/router_test.go @@ -230,6 +230,9 @@ func TestRouter_AdminRoutes_RejectNonAdmin(t *testing.T) { // direct admin (role=="admin", not an assumed session) is now forbidden from // Personal APIs, where the old admin-as-superset model let them through. The // request is denied at the gateway and never reaches the downstream service. +// Every path here is a concrete registered route (the resolver classifies exact +// gin patterns, so a non-real trailing-slash path like "/api/expenses/" would +// 404 downstream and falls to the Authenticated default instead). func TestRouter_PersonalRoutes_RejectDirectAdmin(t *testing.T) { doRequest := setupGateway(t, adminValidator()) @@ -238,7 +241,7 @@ func TestRouter_PersonalRoutes_RejectDirectAdmin(t *testing.T) { path string }{ {http.MethodGet, "/api/finance/periods/current"}, - {http.MethodGet, "/api/expenses/"}, + {http.MethodGet, "/api/expenses"}, {http.MethodPost, "/api/datarights/exports"}, {http.MethodPost, "/api/auth/onboarding-complete"}, } From 2b218dfa2f0a8f6c41ed96a6692ec2d998119620 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 02:17:49 +0100 Subject: [PATCH 59/70] feat(access): add generic fail-fast route binder - 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 --- services/access/bind.go | 26 ++++++++++++++++++ services/access/bind_test.go | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 services/access/bind.go create mode 100644 services/access/bind_test.go diff --git a/services/access/bind.go b/services/access/bind.go new file mode 100644 index 00000000..a095d67d --- /dev/null +++ b/services/access/bind.go @@ -0,0 +1,26 @@ +package access + +import "fmt" + +// BindRoutes registers every Registry route for a service by looking its +// handler up by ID and handing the method+path+handler to register. It is the +// single fail-fast binding point shared by every downstream service: a Registry +// entry with no handler panics (a programming error caught at startup and in +// the per-service registration test), so a route can never be served without +// being classified, and no service hand-maintains its own route list. +// +// It is generic over the handler type so the access module stays free of any +// web-framework dependency; callers pass gin.HandlerFunc (via an engine.Handle +// adapter) as H. +func BindRoutes[H any](service string, handlers map[string]H, register func(method, path string, handler H)) { + for _, r := range RoutesFor(service) { + handler, ok := handlers[r.ID] + if !ok { + panic(fmt.Sprintf( + "no handler bound for route %s (%s %s); add it to the service handler map or the services/access Registry", + r.ID, r.Method, r.Path, + )) + } + register(r.Method, r.Path, handler) + } +} diff --git a/services/access/bind_test.go b/services/access/bind_test.go new file mode 100644 index 00000000..c0d1583c --- /dev/null +++ b/services/access/bind_test.go @@ -0,0 +1,52 @@ +package access + +import ( + "strings" + "testing" +) + +// TestBindRoutes_RegistersEveryRouteForService confirms BindRoutes hands every +// Registry route for a service to register exactly once, in Registry order, +// looked up by ID. +func TestBindRoutes_RegistersEveryRouteForService(t *testing.T) { + want := RoutesFor("expense") + + handlers := make(map[string]int, len(want)) + for _, r := range want { + handlers[r.ID] = 1 + } + + var registered []string + BindRoutes("expense", handlers, func(method, path string, _ int) { + registered = append(registered, method+" "+path) + }) + + if len(registered) != len(want) { + t.Fatalf("registered %d routes, want %d", len(registered), len(want)) + } + for i, r := range want { + if got := r.Method + " " + r.Path; registered[i] != got { + t.Errorf("registered[%d] = %q, want %q (order must follow Registry)", i, registered[i], got) + } + } +} + +// TestBindRoutes_PanicsOnMissingHandler is the fail-fast guarantee: a Registry +// route with no handler in the map panics with a message naming the route, so a +// classified-but-unbound route is caught at startup rather than silently +// dropped. +func TestBindRoutes_PanicsOnMissingHandler(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for a route with no handler") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "auth.register") { + t.Errorf("panic message = %v, want it to name the unbound route auth.register", r) + } + }() + + // An empty handler map cannot cover any auth route, so the first one panics. + BindRoutes("auth", map[string]int{}, func(_, _ string, _ int) {}) +} From 2c48dbeec337798b129d3b1008d7851ef60e2715 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 02:27:59 +0100 Subject: [PATCH 60/70] feat(access): add registration coverage verifier - 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 --- services/access/coverage.go | 73 ++++++++++++++++++++++++++++++++ services/access/coverage_test.go | 64 ++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 services/access/coverage.go create mode 100644 services/access/coverage_test.go diff --git a/services/access/coverage.go b/services/access/coverage.go new file mode 100644 index 00000000..a27ac0b4 --- /dev/null +++ b/services/access/coverage.go @@ -0,0 +1,73 @@ +package access + +import ( + "fmt" + "sort" + "strings" +) + +// RegisteredRoute is a concrete method+path a service actually registered on +// its router, typically collected from gin's engine.Routes(). Services hand a +// slice of these to VerifyRegistration to prove their router matches the +// Registry. +type RegisteredRoute struct { + Method string + Path string +} + +// VerifyRegistration compares a service's registered routes against the +// Registry in both directions and returns a descriptive error (naming the +// offending routes and pointing at the Registry) when they diverge: +// +// 1. every registered /api route corresponds byte-for-byte to a Registry entry +// for this service, so no ad-hoc route escaped classification, and +// 2. every Registry entry for this service was registered, so no classified +// route was left unbound. +// +// Only /api routes are considered, so a caller that also registers health or +// metrics endpoints is not penalized. Paths are compared exactly (including +// ":id"/":groupId" params), which is what pins the Registry patterns to gin's +// real registration. +func VerifyRegistration(service string, registered []RegisteredRoute) error { + want := RoutesFor(service) + + wanted := make(map[RegisteredRoute]bool, len(want)) + for _, r := range want { + wanted[RegisteredRoute{Method: r.Method, Path: r.Path}] = true + } + + registeredSet := make(map[RegisteredRoute]bool, len(registered)) + var extra []string + for _, r := range registered { + registeredSet[r] = true + if !strings.HasPrefix(r.Path, "/api/") { + continue + } + if !wanted[r] { + extra = append(extra, r.Method+" "+r.Path) + } + } + + var missing []string + for _, r := range want { + if !registeredSet[RegisteredRoute{Method: r.Method, Path: r.Path}] { + missing = append(missing, r.Method+" "+r.Path) + } + } + + if len(extra) == 0 && len(missing) == 0 { + return nil + } + + sort.Strings(extra) + sort.Strings(missing) + var b strings.Builder + fmt.Fprintf(&b, "service %q routes diverge from the services/access Registry", service) + if len(extra) > 0 { + fmt.Fprintf(&b, "\n registered but not in the Registry (add an entry with an Access level, or remove the route): %s", strings.Join(extra, ", ")) + } + if len(missing) > 0 { + fmt.Fprintf(&b, "\n in the Registry but not registered (bind a handler by ID): %s", strings.Join(missing, ", ")) + } + return fmt.Errorf("%s", b.String()) +} diff --git a/services/access/coverage_test.go b/services/access/coverage_test.go new file mode 100644 index 00000000..d62ae753 --- /dev/null +++ b/services/access/coverage_test.go @@ -0,0 +1,64 @@ +package access + +import ( + "strings" + "testing" +) + +// registeredFromRegistry returns the RegisteredRoute set a correctly wired +// service would produce: exactly its Registry entries. +func registeredFromRegistry(service string) []RegisteredRoute { + routes := RoutesFor(service) + registered := make([]RegisteredRoute, 0, len(routes)) + for _, r := range routes { + registered = append(registered, RegisteredRoute{Method: r.Method, Path: r.Path}) + } + return registered +} + +// TestVerifyRegistration_MatchingRouterPasses confirms a router that registers +// exactly the Registry entries for a service (plus ignored health/metrics) +// verifies clean. +func TestVerifyRegistration_MatchingRouterPasses(t *testing.T) { + for _, service := range []string{"auth", "finance", "expense", "datarights"} { + registered := append(registeredFromRegistry(service), + RegisteredRoute{Method: "GET", Path: "/health"}, + RegisteredRoute{Method: "GET", Path: "/metrics"}, + ) + if err := VerifyRegistration(service, registered); err != nil { + t.Errorf("VerifyRegistration(%q) = %v, want nil", service, err) + } + } +} + +// TestVerifyRegistration_ExtraRouteIsReported is direction 1: an /api route +// with no Registry entry is flagged and named. +func TestVerifyRegistration_ExtraRouteIsReported(t *testing.T) { + registered := append(registeredFromRegistry("auth"), + RegisteredRoute{Method: "GET", Path: "/api/auth/secret"}, + ) + + err := VerifyRegistration("auth", registered) + if err == nil { + t.Fatal("expected an error for a route missing from the Registry") + } + if !strings.Contains(err.Error(), "GET /api/auth/secret") { + t.Errorf("error should name the offending route, got: %v", err) + } +} + +// TestVerifyRegistration_MissingRouteIsReported is direction 2: a Registry +// entry the service failed to register is flagged and named. +func TestVerifyRegistration_MissingRouteIsReported(t *testing.T) { + registered := registeredFromRegistry("auth") + // Drop the last route to simulate a forgotten binding. + registered = registered[:len(registered)-1] + + err := VerifyRegistration("auth", registered) + if err == nil { + t.Fatal("expected an error for an unregistered Registry route") + } + if !strings.Contains(err.Error(), "GET /api/admin/users") { + t.Errorf("error should name the missing route, got: %v", err) + } +} From 8acafbede313a385589a2944ca26e10774a2bdc5 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 02:28:12 +0100 Subject: [PATCH 61/70] feat(services): register routes from the shared registry - 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 --- services/auth/Dockerfile | 2 + services/auth/go.mod | 3 + .../internal/handler/registration_test.go | 35 +++++++++++ services/auth/internal/handler/rest.go | 42 ++++++++------ services/datarights/Dockerfile | 2 + services/datarights/cmd/main.go | 4 +- services/datarights/go.mod | 3 + .../datarights/internal/handler/deletion.go | 11 ++-- .../internal/handler/deletion_test.go | 5 +- .../internal/handler/registration_test.go | 35 +++++++++++ services/datarights/internal/handler/rest.go | 34 ++++++++--- .../datarights/internal/handler/rest_test.go | 5 +- services/expense/Dockerfile | 2 + services/expense/go.mod | 3 + .../internal/handler/registration_test.go | 34 +++++++++++ services/expense/internal/handler/rest.go | 32 ++++++---- services/finance/Dockerfile | 2 + services/finance/go.mod | 3 + .../internal/handler/registration_test.go | 34 +++++++++++ services/finance/internal/handler/rest.go | 58 +++++++++++-------- 20 files changed, 277 insertions(+), 72 deletions(-) create mode 100644 services/auth/internal/handler/registration_test.go create mode 100644 services/datarights/internal/handler/registration_test.go create mode 100644 services/expense/internal/handler/registration_test.go create mode 100644 services/finance/internal/handler/registration_test.go diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index 05db43d8..f0bcf8a4 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /app # Copy module files for dependency caching. COPY auth/go.mod auth/go.sum* ./auth/ +COPY access/go.mod access/go.sum* ./access/ COPY dbmigrate/go.mod dbmigrate/go.sum* ./dbmigrate/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ @@ -16,6 +17,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # Copy source. COPY auth/ ./auth/ +COPY access/ ./access/ COPY dbmigrate/ ./dbmigrate/ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ diff --git a/services/auth/go.mod b/services/auth/go.mod index 51deb701..1f0fc884 100644 --- a/services/auth/go.mod +++ b/services/auth/go.mod @@ -3,6 +3,7 @@ module github.com/ItsThompson/gofin/services/auth go 1.26 require ( + github.com/ItsThompson/gofin/services/access v0.0.0 github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 @@ -16,6 +17,8 @@ require ( google.golang.org/protobuf v1.36.11 ) +replace github.com/ItsThompson/gofin/services/access => ../access + replace github.com/ItsThompson/gofin/services/healthcheck => ../healthcheck replace github.com/ItsThompson/gofin/services/metrics => ../metrics diff --git a/services/auth/internal/handler/registration_test.go b/services/auth/internal/handler/registration_test.go new file mode 100644 index 00000000..ee02f4ea --- /dev/null +++ b/services/auth/internal/handler/registration_test.go @@ -0,0 +1,35 @@ +package handler + +import ( + "io" + "log/slog" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/access" +) + +// TestRegisterRoutes_MatchesRegistry builds the real engine via the registry- +// driven RegisterRoutes with nil service deps (gin does not execute handlers at +// registration) and asserts the registered routes match the services/access +// Registry in both directions: no ad-hoc route escapes the Registry, and every +// auth Registry entry is bound to a handler. Adding an unclassified route, or a +// Registry entry with no handler, fails here in auth's own module (which the CI +// test-backend job runs), pointing at the Registry. +func TestRegisterRoutes_MatchesRegistry(t *testing.T) { + gin.SetMode(gin.TestMode) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + engine := gin.New() + NewRESTHandler(nil, logger, false, "").RegisterRoutes(engine) + + registered := make([]access.RegisteredRoute, 0) + for _, r := range engine.Routes() { + registered = append(registered, access.RegisteredRoute{Method: r.Method, Path: r.Path}) + } + + if err := access.VerifyRegistration("auth", registered); err != nil { + t.Fatal(err) + } +} diff --git a/services/auth/internal/handler/rest.go b/services/auth/internal/handler/rest.go index 9be51e91..67bbb4a2 100644 --- a/services/auth/internal/handler/rest.go +++ b/services/auth/internal/handler/rest.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/ItsThompson/gofin/services/access" "github.com/ItsThompson/gofin/services/auth/internal/model" "github.com/ItsThompson/gofin/services/auth/internal/service" "github.com/ItsThompson/gofin/services/metrics" @@ -30,25 +31,32 @@ func NewRESTHandler(authService *service.AuthService, logger *slog.Logger, cooki } } -// RegisterRoutes sets up the Gin routes for auth endpoints. +// RegisterRoutes registers every auth-owned route from the shared access +// Registry, binding each handler by ID. It is the single registration entry +// point shared by main.go and the registration coverage test, so a route can +// never be served without a Registry entry (which carries its access level). func (h *RESTHandler) RegisterRoutes(r *gin.Engine) { - auth := r.Group("/api/auth") - { - auth.POST("/register", h.Register) - auth.POST("/login", h.Login) - auth.POST("/refresh", h.Refresh) - auth.POST("/logout", h.Logout) - auth.GET("/me", h.Me) - auth.PUT("/me", h.UpdateProfile) - auth.POST("/me/password", h.ChangePassword) - auth.POST("/onboarding-complete", h.CompleteOnboarding) - auth.POST("/assume", h.AssumeIdentity) - auth.POST("/restore", h.RestoreIdentity) - } + access.BindRoutes("auth", h.handlers(), func(method, path string, handler gin.HandlerFunc) { + r.Handle(method, path, handler) + }) +} - admin := r.Group("/api/admin") - { - admin.GET("/users", h.ListUsers) +// handlers maps each auth Registry route ID to its gin handler. A Registry +// entry with no handler here (or a handler with no entry) is caught by +// BindRoutes at startup and by the registration coverage test. +func (h *RESTHandler) handlers() map[string]gin.HandlerFunc { + return map[string]gin.HandlerFunc{ + "auth.register": h.Register, + "auth.login": h.Login, + "auth.refresh": h.Refresh, + "auth.logout": h.Logout, + "auth.me.get": h.Me, + "auth.me.update": h.UpdateProfile, + "auth.me.password": h.ChangePassword, + "auth.onboarding_complete": h.CompleteOnboarding, + "auth.assume": h.AssumeIdentity, + "auth.restore": h.RestoreIdentity, + "admin.users.list": h.ListUsers, } } diff --git a/services/datarights/Dockerfile b/services/datarights/Dockerfile index e465d25e..7bc7db97 100644 --- a/services/datarights/Dockerfile +++ b/services/datarights/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /app # Copy module files for dependency caching. COPY datarights/go.mod datarights/go.sum* ./datarights/ +COPY access/go.mod access/go.sum* ./access/ COPY auth/go.mod auth/go.sum* ./auth/ COPY dbmigrate/go.mod dbmigrate/go.sum* ./dbmigrate/ COPY expense/go.mod expense/go.sum* ./expense/ @@ -19,6 +20,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # Copy source. COPY datarights/ ./datarights/ +COPY access/ ./access/ COPY auth/ ./auth/ COPY dbmigrate/ ./dbmigrate/ COPY expense/ ./expense/ diff --git a/services/datarights/cmd/main.go b/services/datarights/cmd/main.go index 89878af1..a7744f12 100644 --- a/services/datarights/cmd/main.go +++ b/services/datarights/cmd/main.go @@ -202,10 +202,8 @@ func run() error { metrics.Register(router) restHandler := handler.NewRESTHandler(exportSvc, logger) - restHandler.RegisterRoutes(router) - deletionHandler := handler.NewDeletionHandler(deletionSvc, logger) - deletionHandler.RegisterRoutes(router) + handler.RegisterRoutes(router, restHandler, deletionHandler) httpServer := &http.Server{ Addr: ":" + cfg.RESTPort, diff --git a/services/datarights/go.mod b/services/datarights/go.mod index f396247d..30f300db 100644 --- a/services/datarights/go.mod +++ b/services/datarights/go.mod @@ -3,6 +3,7 @@ module github.com/ItsThompson/gofin/services/datarights go 1.26 require ( + github.com/ItsThompson/gofin/services/access v0.0.0 github.com/ItsThompson/gofin/services/auth v0.0.0 github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 github.com/ItsThompson/gofin/services/expense v0.0.0 @@ -16,6 +17,8 @@ require ( google.golang.org/grpc v1.80.0 ) +replace github.com/ItsThompson/gofin/services/access => ../access + replace github.com/ItsThompson/gofin/services/auth => ../auth replace github.com/ItsThompson/gofin/services/dbmigrate => ../dbmigrate diff --git a/services/datarights/internal/handler/deletion.go b/services/datarights/internal/handler/deletion.go index bc2cf5b0..32422349 100644 --- a/services/datarights/internal/handler/deletion.go +++ b/services/datarights/internal/handler/deletion.go @@ -24,12 +24,11 @@ func NewDeletionHandler(deletionService *service.DeletionService, logger *slog.L } } -// RegisterRoutes sets up the Gin routes for deletion endpoints. -func (h *DeletionHandler) RegisterRoutes(r *gin.Engine) { - deletions := r.Group("/api/datarights/deletions") - { - deletions.POST("", h.CreateDeletion) - deletions.GET("/:id", h.GetDeletion) +// handlers maps the datarights deletion Registry route IDs to gin handlers. +func (h *DeletionHandler) handlers() map[string]gin.HandlerFunc { + return map[string]gin.HandlerFunc{ + "datarights.deletions.create": h.CreateDeletion, + "datarights.deletions.get": h.GetDeletion, } } diff --git a/services/datarights/internal/handler/deletion_test.go b/services/datarights/internal/handler/deletion_test.go index d30d5fbe..c4fbb722 100644 --- a/services/datarights/internal/handler/deletion_test.go +++ b/services/datarights/internal/handler/deletion_test.go @@ -222,10 +222,11 @@ func setupDeletionTestRouter( } svc := service.NewDeletionService(repo, logger, opts...) - h := NewDeletionHandler(svc, logger) + deletion := NewDeletionHandler(svc, logger) + rest := NewRESTHandler(nil, logger) router := gin.New() - h.RegisterRoutes(router) + RegisterRoutes(router, rest, deletion) return router } diff --git a/services/datarights/internal/handler/registration_test.go b/services/datarights/internal/handler/registration_test.go new file mode 100644 index 00000000..c8667fe3 --- /dev/null +++ b/services/datarights/internal/handler/registration_test.go @@ -0,0 +1,35 @@ +package handler + +import ( + "io" + "log/slog" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/access" +) + +// TestRegisterRoutes_MatchesRegistry builds the real engine via the registry- +// driven RegisterRoutes with nil service deps (gin does not execute handlers at +// registration) and asserts the registered routes match the services/access +// Registry in both directions. datarights binds handlers from both the export +// and deletion handlers; adding an unclassified route, or a Registry entry with +// no handler, fails here in datarights' own module (run by CI), pointing at the +// Registry. +func TestRegisterRoutes_MatchesRegistry(t *testing.T) { + gin.SetMode(gin.TestMode) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + engine := gin.New() + RegisterRoutes(engine, NewRESTHandler(nil, logger), NewDeletionHandler(nil, logger)) + + registered := make([]access.RegisteredRoute, 0) + for _, r := range engine.Routes() { + registered = append(registered, access.RegisteredRoute{Method: r.Method, Path: r.Path}) + } + + if err := access.VerifyRegistration("datarights", registered); err != nil { + t.Fatal(err) + } +} diff --git a/services/datarights/internal/handler/rest.go b/services/datarights/internal/handler/rest.go index fcfe3099..092e3929 100644 --- a/services/datarights/internal/handler/rest.go +++ b/services/datarights/internal/handler/rest.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/ItsThompson/gofin/services/access" exportmetrics "github.com/ItsThompson/gofin/services/datarights/internal/metrics" "github.com/ItsThompson/gofin/services/datarights/internal/model" "github.com/ItsThompson/gofin/services/datarights/internal/service" @@ -26,13 +27,32 @@ func NewRESTHandler(exportService *service.ExportService, logger *slog.Logger) * } } -// RegisterRoutes sets up the Gin routes for datarights endpoints. -func (h *RESTHandler) RegisterRoutes(r *gin.Engine) { - exports := r.Group("/api/datarights/exports") - { - exports.POST("", h.CreateExport) - exports.GET("", h.ListExports) - exports.GET("/:id", h.GetExport) +// RegisterRoutes registers every datarights-owned route from the shared access +// Registry, binding handlers from both the export and deletion handlers by ID. +// It is the single registration entry point shared by main.go and the +// registration coverage test. datarights is the one service whose routes span +// two handlers, so it merges both ID->handler maps before binding; a route can +// never be served without a Registry entry (which carries its access level). +func RegisterRoutes(r *gin.Engine, rest *RESTHandler, deletion *DeletionHandler) { + handlers := make(map[string]gin.HandlerFunc) + for id, fn := range rest.handlers() { + handlers[id] = fn + } + for id, fn := range deletion.handlers() { + handlers[id] = fn + } + + access.BindRoutes("datarights", handlers, func(method, path string, handler gin.HandlerFunc) { + r.Handle(method, path, handler) + }) +} + +// handlers maps the datarights export Registry route IDs to gin handlers. +func (h *RESTHandler) handlers() map[string]gin.HandlerFunc { + return map[string]gin.HandlerFunc{ + "datarights.exports.create": h.CreateExport, + "datarights.exports.list": h.ListExports, + "datarights.exports.get": h.GetExport, } } diff --git a/services/datarights/internal/handler/rest_test.go b/services/datarights/internal/handler/rest_test.go index 7735b32c..738ca27a 100644 --- a/services/datarights/internal/handler/rest_test.go +++ b/services/datarights/internal/handler/rest_test.go @@ -96,10 +96,11 @@ func setupTestRouter(repo repository.JobRepository) *gin.Engine { gin.SetMode(gin.TestMode) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) svc := service.NewExportService(repo, logger) - h := NewRESTHandler(svc, logger) + rest := NewRESTHandler(svc, logger) + deletion := NewDeletionHandler(nil, logger) router := gin.New() - h.RegisterRoutes(router) + RegisterRoutes(router, rest, deletion) return router } diff --git a/services/expense/Dockerfile b/services/expense/Dockerfile index a9903831..4479a900 100644 --- a/services/expense/Dockerfile +++ b/services/expense/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /app # Copy module files for dependency caching. COPY expense/go.mod expense/go.sum* ./expense/ +COPY access/go.mod access/go.sum* ./access/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ RUN --mount=type=cache,target=/go/pkg/mod \ @@ -15,6 +16,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # Copy source. COPY expense/ ./expense/ +COPY access/ ./access/ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ diff --git a/services/expense/go.mod b/services/expense/go.mod index 48a9455b..5aa490c9 100644 --- a/services/expense/go.mod +++ b/services/expense/go.mod @@ -3,6 +3,7 @@ module github.com/ItsThompson/gofin/services/expense go 1.26 require ( + github.com/ItsThompson/gofin/services/access v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 github.com/codenotary/immudb v1.11.0 @@ -13,6 +14,8 @@ require ( google.golang.org/protobuf v1.36.11 ) +replace github.com/ItsThompson/gofin/services/access => ../access + replace github.com/ItsThompson/gofin/services/healthcheck => ../healthcheck replace github.com/ItsThompson/gofin/services/metrics => ../metrics diff --git a/services/expense/internal/handler/registration_test.go b/services/expense/internal/handler/registration_test.go new file mode 100644 index 00000000..19a28cfa --- /dev/null +++ b/services/expense/internal/handler/registration_test.go @@ -0,0 +1,34 @@ +package handler + +import ( + "io" + "log/slog" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/access" +) + +// TestRegisterRoutes_MatchesRegistry builds the real engine via the registry- +// driven RegisterRoutes with nil service deps (gin does not execute handlers at +// registration) and asserts the registered routes match the services/access +// Registry in both directions. Adding an unclassified route, or a Registry +// entry with no handler, fails here in expense's own module (run by CI), +// pointing at the Registry. +func TestRegisterRoutes_MatchesRegistry(t *testing.T) { + gin.SetMode(gin.TestMode) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + engine := gin.New() + NewRESTHandler(nil, logger).RegisterRoutes(engine) + + registered := make([]access.RegisteredRoute, 0) + for _, r := range engine.Routes() { + registered = append(registered, access.RegisteredRoute{Method: r.Method, Path: r.Path}) + } + + if err := access.VerifyRegistration("expense", registered); err != nil { + t.Fatal(err) + } +} diff --git a/services/expense/internal/handler/rest.go b/services/expense/internal/handler/rest.go index 64260f54..3695af47 100644 --- a/services/expense/internal/handler/rest.go +++ b/services/expense/internal/handler/rest.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/ItsThompson/gofin/services/access" "github.com/ItsThompson/gofin/services/expense/internal/model" "github.com/ItsThompson/gofin/services/expense/internal/service" ) @@ -25,17 +26,28 @@ func NewRESTHandler(expenseService *service.ExpenseService, logger *slog.Logger) } } -// RegisterRoutes sets up the Gin routes for expense endpoints. +// RegisterRoutes registers every expense-owned route from the shared access +// Registry, binding each handler by ID. It is the single registration entry +// point shared by main.go and the registration coverage test, so a route can +// never be served without a Registry entry (which carries its access level). func (h *RESTHandler) RegisterRoutes(r *gin.Engine) { - expenses := r.Group("/api/expenses") - { - expenses.POST("", h.CreateExpense) - expenses.GET("", h.GetExpenses) - expenses.GET("/suggestions", h.GetExpenseSuggestions) - expenses.GET("/prorata/:groupId", h.GetProRataGroup) - expenses.GET("/:id", h.GetExpense) - expenses.POST("/:id/correct", h.CorrectExpense) - expenses.GET("/:id/history", h.GetCorrectionHistory) + access.BindRoutes("expense", h.handlers(), func(method, path string, handler gin.HandlerFunc) { + r.Handle(method, path, handler) + }) +} + +// handlers maps each expense Registry route ID to its gin handler. A Registry +// entry with no handler here (or a handler with no entry) is caught by +// BindRoutes at startup and by the registration coverage test. +func (h *RESTHandler) handlers() map[string]gin.HandlerFunc { + return map[string]gin.HandlerFunc{ + "expense.create": h.CreateExpense, + "expense.list": h.GetExpenses, + "expense.suggestions": h.GetExpenseSuggestions, + "expense.prorata.group": h.GetProRataGroup, + "expense.get": h.GetExpense, + "expense.correct": h.CorrectExpense, + "expense.history": h.GetCorrectionHistory, } } diff --git a/services/finance/Dockerfile b/services/finance/Dockerfile index 196fa8f2..60cf3663 100644 --- a/services/finance/Dockerfile +++ b/services/finance/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /app # Copy module files for dependency caching. COPY finance/go.mod finance/go.sum* ./finance/ +COPY access/go.mod access/go.sum* ./access/ COPY dbmigrate/go.mod dbmigrate/go.sum* ./dbmigrate/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ @@ -17,6 +18,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # Copy source. COPY finance/ ./finance/ +COPY access/ ./access/ COPY dbmigrate/ ./dbmigrate/ COPY healthcheck/ ./healthcheck/ COPY metrics/ ./metrics/ diff --git a/services/finance/go.mod b/services/finance/go.mod index ef820363..1f804a62 100644 --- a/services/finance/go.mod +++ b/services/finance/go.mod @@ -3,6 +3,7 @@ module github.com/ItsThompson/gofin/services/finance go 1.26 require ( + github.com/ItsThompson/gofin/services/access v0.0.0 github.com/ItsThompson/gofin/services/dbmigrate v0.0.0 github.com/ItsThompson/gofin/services/expense v0.0.0-00010101000000-000000000000 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 @@ -15,6 +16,8 @@ require ( google.golang.org/protobuf v1.36.11 ) +replace github.com/ItsThompson/gofin/services/access => ../access + replace github.com/ItsThompson/gofin/services/healthcheck => ../healthcheck replace github.com/ItsThompson/gofin/services/metrics => ../metrics diff --git a/services/finance/internal/handler/registration_test.go b/services/finance/internal/handler/registration_test.go new file mode 100644 index 00000000..f030e0cf --- /dev/null +++ b/services/finance/internal/handler/registration_test.go @@ -0,0 +1,34 @@ +package handler + +import ( + "io" + "log/slog" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/access" +) + +// TestRegisterRoutes_MatchesRegistry builds the real engine via the registry- +// driven RegisterRoutes with nil service deps (gin does not execute handlers at +// registration) and asserts the registered routes match the services/access +// Registry in both directions. Adding an unclassified route, or a Registry +// entry with no handler, fails here in finance's own module (run by CI), +// pointing at the Registry. +func TestRegisterRoutes_MatchesRegistry(t *testing.T) { + gin.SetMode(gin.TestMode) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + engine := gin.New() + NewRESTHandler(nil, logger).RegisterRoutes(engine) + + registered := make([]access.RegisteredRoute, 0) + for _, r := range engine.Routes() { + registered = append(registered, access.RegisteredRoute{Method: r.Method, Path: r.Path}) + } + + if err := access.VerifyRegistration("finance", registered); err != nil { + t.Fatal(err) + } +} diff --git a/services/finance/internal/handler/rest.go b/services/finance/internal/handler/rest.go index d1ebd2e1..fcdabcf6 100644 --- a/services/finance/internal/handler/rest.go +++ b/services/finance/internal/handler/rest.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/ItsThompson/gofin/services/access" "github.com/ItsThompson/gofin/services/finance/internal/model" "github.com/ItsThompson/gofin/services/finance/internal/service" ) @@ -25,32 +26,39 @@ func NewRESTHandler(financeService *service.FinanceService, logger *slog.Logger) } } -// RegisterRoutes sets up the Gin routes for finance endpoints. +// RegisterRoutes registers every finance-owned route from the shared access +// Registry, binding each handler by ID. It is the single registration entry +// point shared by main.go and the registration coverage test, so a route can +// never be served without a Registry entry (which carries its access level). func (h *RESTHandler) RegisterRoutes(r *gin.Engine) { - finance := r.Group("/api/finance") - { - finance.POST("/onboarding", h.CompleteOnboarding) - finance.GET("/defaults", h.GetDefaults) - finance.PUT("/defaults", h.UpdateDefaults) - finance.GET("/periods/current", h.GetCurrentPeriod) - finance.GET("/periods", h.ListPeriods) - finance.POST("/periods", h.CreatePeriod) - finance.PUT("/periods/:id", h.UpdatePeriod) - finance.GET("/tags", h.ListTags) - finance.POST("/tags", h.CreateTag) - finance.PUT("/tags/:id", h.UpdateTag) - finance.DELETE("/tags/:id", h.DeleteTag) - - // Dashboard aggregation endpoints - finance.GET("/summary", h.GetPeriodSummary) - finance.GET("/spending/by-tag", h.GetSpendingByTag) - finance.GET("/spending/cumulative", h.GetCumulativeSpend) - finance.GET("/spending/comparison", h.GetHistoricalComparison) - finance.GET("/spending/trends", h.GetSpendingTrends) - - // Pro-rata endpoints - finance.POST("/prorata", h.CreateProRataExpense) - finance.GET("/prorata/upcoming", h.GetUpcomingProRata) + access.BindRoutes("finance", h.handlers(), func(method, path string, handler gin.HandlerFunc) { + r.Handle(method, path, handler) + }) +} + +// handlers maps each finance Registry route ID to its gin handler. A Registry +// entry with no handler here (or a handler with no entry) is caught by +// BindRoutes at startup and by the registration coverage test. +func (h *RESTHandler) handlers() map[string]gin.HandlerFunc { + return map[string]gin.HandlerFunc{ + "finance.onboarding": h.CompleteOnboarding, + "finance.defaults.get": h.GetDefaults, + "finance.defaults.update": h.UpdateDefaults, + "finance.periods.current": h.GetCurrentPeriod, + "finance.periods.list": h.ListPeriods, + "finance.periods.create": h.CreatePeriod, + "finance.periods.update": h.UpdatePeriod, + "finance.tags.list": h.ListTags, + "finance.tags.create": h.CreateTag, + "finance.tags.update": h.UpdateTag, + "finance.tags.delete": h.DeleteTag, + "finance.summary": h.GetPeriodSummary, + "finance.spending.by_tag": h.GetSpendingByTag, + "finance.spending.cumulative": h.GetCumulativeSpend, + "finance.spending.comparison": h.GetHistoricalComparison, + "finance.spending.trends": h.GetSpendingTrends, + "finance.prorata.create": h.CreateProRataExpense, + "finance.prorata.upcoming": h.GetUpcomingProRata, } } From 710e91cf8b307767de8f6e35e1b4ed01f5ba363c Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 02:54:53 +0100 Subject: [PATCH 62/70] refactor(shell): derive route guards from access metadata - 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 --- .../app/features/shell-layout/DesktopNav.tsx | 26 ++ .../app/features/shell-layout/Forbidden.tsx | 26 ++ .../features/shell-layout/LogExpenseFab.tsx | 24 ++ .../app/features/shell-layout/MobileNav.tsx | 56 ++++ .../app/features/shell-layout/Navbar.tsx | 66 +++++ .../shell-layout/ReturnToAdminButton.tsx | 27 ++ .../shell-layout/hooks/useAuthLayoutGuards.ts | 76 +++++ .../shell/app/features/shell-layout/index.ts | 16 ++ .../app/features/shell-layout/nav-links.ts | 29 ++ .../shell/app/features/shell-layout/types.ts | 48 ++++ frontend/apps/shell/app/lib/route-access.ts | 40 +++ .../apps/shell/app/routes/admin-users.tsx | 2 + frontend/apps/shell/app/routes/admin.tsx | 2 + .../apps/shell/app/routes/auth-layout.tsx | 263 ++++-------------- frontend/apps/shell/app/routes/dashboard.tsx | 2 + .../apps/shell/app/routes/expenses-new.tsx | 2 + frontend/apps/shell/app/routes/expenses.tsx | 2 + frontend/apps/shell/app/routes/history.tsx | 2 + frontend/apps/shell/app/routes/home.tsx | 10 +- frontend/apps/shell/app/routes/onboarding.tsx | 2 + frontend/apps/shell/app/routes/settings.tsx | 2 + frontend/apps/shell/eslint.config.js | 17 +- 22 files changed, 524 insertions(+), 216 deletions(-) create mode 100644 frontend/apps/shell/app/features/shell-layout/DesktopNav.tsx create mode 100644 frontend/apps/shell/app/features/shell-layout/Forbidden.tsx create mode 100644 frontend/apps/shell/app/features/shell-layout/LogExpenseFab.tsx create mode 100644 frontend/apps/shell/app/features/shell-layout/MobileNav.tsx create mode 100644 frontend/apps/shell/app/features/shell-layout/Navbar.tsx create mode 100644 frontend/apps/shell/app/features/shell-layout/ReturnToAdminButton.tsx create mode 100644 frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts create mode 100644 frontend/apps/shell/app/features/shell-layout/index.ts create mode 100644 frontend/apps/shell/app/features/shell-layout/nav-links.ts create mode 100644 frontend/apps/shell/app/features/shell-layout/types.ts create mode 100644 frontend/apps/shell/app/lib/route-access.ts diff --git a/frontend/apps/shell/app/features/shell-layout/DesktopNav.tsx b/frontend/apps/shell/app/features/shell-layout/DesktopNav.tsx new file mode 100644 index 00000000..0d1286f3 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/DesktopNav.tsx @@ -0,0 +1,26 @@ +import { NavLink } from "react-router"; +import type { DesktopNavProps } from "./types"; + +/** Desktop navigation links, hidden on mobile. */ +export function DesktopNav({ navLinks }: DesktopNavProps) { + return ( + <nav className="hidden items-center gap-1 md:flex"> + {navLinks.map((link) => ( + <NavLink + key={link.to} + to={link.to} + className={({ isActive }) => + `flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ + isActive + ? "bg-muted text-foreground" + : "text-muted-foreground hover:bg-muted hover:text-foreground" + }` + } + > + <link.icon className="size-4" /> + {link.label} + </NavLink> + ))} + </nav> + ); +} diff --git a/frontend/apps/shell/app/features/shell-layout/Forbidden.tsx b/frontend/apps/shell/app/features/shell-layout/Forbidden.tsx new file mode 100644 index 00000000..e3003188 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/Forbidden.tsx @@ -0,0 +1,26 @@ +import { NavLink } from "react-router"; +import { Button } from "@gofin/ui/components/button"; +import { ShieldAlert } from "lucide-react"; + +/** + * 403 page rendered inside the layout chrome when the matched route's access + * level rejects the current identity (e.g. a direct admin opening a personal + * route by URL, or a regular user opening an admin route). Keeping the navbar + * means the operator can navigate away instead of being silently redirected. + */ +export function Forbidden() { + return ( + <div className="flex flex-col items-center justify-center gap-4 py-24 text-center"> + <ShieldAlert className="size-12 text-muted-foreground" /> + <div> + <h1 className="text-2xl font-bold">403: Access denied</h1> + <p className="mt-1 text-muted-foreground"> + You don't have access to this page. + </p> + </div> + <NavLink to="/"> + <Button variant="default">Go back</Button> + </NavLink> + </div> + ); +} diff --git a/frontend/apps/shell/app/features/shell-layout/LogExpenseFab.tsx b/frontend/apps/shell/app/features/shell-layout/LogExpenseFab.tsx new file mode 100644 index 00000000..7cb95b65 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/LogExpenseFab.tsx @@ -0,0 +1,24 @@ +import { NavLink } from "react-router"; +import { Button } from "@gofin/ui/components/button"; +import { PlusCircle } from "lucide-react"; + +/** + * Mobile-only floating action button linking to the new-expense page. The + * parent decides visibility (hidden for a direct admin, while assuming, and on + * the new-expense page itself). + */ +export function LogExpenseFab() { + return ( + <div className="fixed bottom-6 right-6 z-40 md:hidden"> + <NavLink to="/expenses/new"> + <Button + size="lg" + className="rounded-full shadow-lg size-14" + aria-label="Log Expense" + > + <PlusCircle className="size-6" /> + </Button> + </NavLink> + </div> + ); +} diff --git a/frontend/apps/shell/app/features/shell-layout/MobileNav.tsx b/frontend/apps/shell/app/features/shell-layout/MobileNav.tsx new file mode 100644 index 00000000..9fb26c60 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/MobileNav.tsx @@ -0,0 +1,56 @@ +import { NavLink } from "react-router"; +import { LogOut } from "lucide-react"; +import type { MobileNavProps } from "./types"; + +/** + * Mobile navigation menu, shown when the hamburger is open. Renders the same + * links as the desktop nav plus the username and a logout action. Clicking a + * link fires onNavigate so the parent can close the menu. + */ +export function MobileNav({ + navLinks, + user, + open, + onNavigate, + onLogout, +}: MobileNavProps) { + if (!open) { + return null; + } + + return ( + <nav className="border-t px-4 pb-4 pt-2 md:hidden"> + <div className="flex flex-col gap-1"> + {navLinks.map((link) => ( + <NavLink + key={link.to} + to={link.to} + onClick={onNavigate} + className={({ isActive }) => + `flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${ + isActive + ? "bg-muted text-foreground" + : "text-muted-foreground hover:bg-muted hover:text-foreground" + }` + } + > + <link.icon className="size-4" /> + {link.label} + </NavLink> + ))} + <div className="mt-2 border-t pt-2"> + <div className="px-3 py-1 text-sm text-muted-foreground"> + {user.username} + </div> + <button + onClick={onLogout} + className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + <LogOut className="size-4" /> + Logout + </button> + </div> + </div> + </nav> + ); +} diff --git a/frontend/apps/shell/app/features/shell-layout/Navbar.tsx b/frontend/apps/shell/app/features/shell-layout/Navbar.tsx new file mode 100644 index 00000000..866ccc01 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/Navbar.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { NavLink } from "react-router"; +import { getLandingPath } from "@gofin/core"; +import { Button } from "@gofin/ui/components/button"; +import { LogOut, Menu, X } from "lucide-react"; +import { DesktopNav } from "./DesktopNav"; +import { MobileNav } from "./MobileNav"; +import { navLinksFor } from "./nav-links"; +import type { NavbarProps } from "./types"; + +/** + * Top navigation bar: logo, desktop links, username + logout, and the mobile + * hamburger that toggles the MobileNav. The link set is derived from whether + * the identity is a direct admin; the mobile-open state is navbar-local UI. + */ +export function Navbar({ user, isDirectAdmin, onLogout }: NavbarProps) { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const navLinks = navLinksFor(isDirectAdmin); + + return ( + <header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <div className="mx-auto flex h-14 max-w-7xl items-center px-4"> + <NavLink + to={getLandingPath(user)} + className="mr-6 flex items-center gap-2" + > + <span className="text-lg font-bold">GoFin</span> + </NavLink> + + <DesktopNav navLinks={navLinks} /> + + <div className="flex-1" /> + + <div className="hidden items-center gap-3 md:flex"> + <span className="text-sm text-muted-foreground">{user.username}</span> + <Button variant="ghost" size="sm" onClick={onLogout}> + <LogOut className="size-4" /> + Logout + </Button> + </div> + + <Button + variant="ghost" + size="icon" + className="md:hidden" + onClick={() => setMobileMenuOpen(!mobileMenuOpen)} + aria-label={mobileMenuOpen ? "Close menu" : "Open menu"} + > + {mobileMenuOpen ? ( + <X className="size-5" /> + ) : ( + <Menu className="size-5" /> + )} + </Button> + </div> + + <MobileNav + navLinks={navLinks} + user={user} + open={mobileMenuOpen} + onNavigate={() => setMobileMenuOpen(false)} + onLogout={onLogout} + /> + </header> + ); +} diff --git a/frontend/apps/shell/app/features/shell-layout/ReturnToAdminButton.tsx b/frontend/apps/shell/app/features/shell-layout/ReturnToAdminButton.tsx new file mode 100644 index 00000000..d4326f99 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/ReturnToAdminButton.tsx @@ -0,0 +1,27 @@ +import { Button } from "@gofin/ui/components/button"; +import { ArrowLeftToLine } from "lucide-react"; +import type { ReturnToAdminButtonProps } from "./types"; + +/** + * Floating control shown during identity assumption, letting the operator + * restore their admin identity and return to /admin. + */ +export function ReturnToAdminButton({ + onReturn, + disabled, +}: ReturnToAdminButtonProps) { + return ( + <div className="fixed bottom-6 right-6 z-50"> + <Button + variant="default" + size="lg" + onClick={onReturn} + disabled={disabled} + className="shadow-lg" + > + <ArrowLeftToLine className="size-4" /> + Return to Admin + </Button> + </div> + ); +} diff --git a/frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts b/frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts new file mode 100644 index 00000000..60d57be9 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts @@ -0,0 +1,76 @@ +import { useLocation, useMatches, type UIMatch } from "react-router"; +import { canUseFinanceFeatures, getLandingPath, type User } from "@gofin/core"; +import { canAccess, type RouteAccess } from "@/lib/route-access"; +import type { AuthLayoutGuard } from "../types"; + +/** The route users complete before reaching their finance surface. */ +const ONBOARDING_PATH = "/onboarding"; + +/** The auth state slice the guard needs from the store. */ +interface AuthLayoutState { + user: User | null; + isAuthenticated: boolean; + isAssuming: boolean; + isLoading: boolean; +} + +type AccessHandle = { access?: RouteAccess }; + +/** + * deepestAccess reads the access level from the most specific matched route's + * `handle`. An unclassified route falls back to "authenticated" (fail-safe: it + * still requires a session but no role). + */ +function deepestAccess(matches: UIMatch[]): RouteAccess { + const matched = [...matches] + .reverse() + .find((match) => (match.handle as AccessHandle | undefined)?.access); + return (matched?.handle as AccessHandle | undefined)?.access ?? "authenticated"; +} + +/** + * useAuthLayoutGuards derives the layout's behavior from auth state and the + * matched route's `handle.access`, replacing the old FINANCE_ROUTES array and + * the hasCompletedOnboarding redirect hack. + * + * Precedence: loading -> unauthenticated -> access (403) -> onboarding. The + * access check runs before onboarding, so a direct admin on a personal route + * (including /onboarding) is forbidden rather than redirected. Onboarding is + * role-driven: only finance-capable users are ever routed through it, so + * admins are never sent to /onboarding regardless of hasCompletedOnboarding. + */ +export function useAuthLayoutGuards({ + user, + isAuthenticated, + isAssuming, + isLoading, +}: AuthLayoutState): AuthLayoutGuard { + const matches = useMatches(); + const { pathname } = useLocation(); + const access = deepestAccess(matches); + + if (isLoading) { + return { status: "loading" }; + } + if (!isAuthenticated || !user) { + return { status: "redirect", to: "/login" }; + } + if (!canAccess(user, isAssuming, access)) { + return { status: "forbidden" }; + } + + if (canUseFinanceFeatures(user)) { + const onOnboarding = pathname === ONBOARDING_PATH; + if (!user.hasCompletedOnboarding && !onOnboarding) { + return { status: "redirect", to: ONBOARDING_PATH }; + } + if (user.hasCompletedOnboarding && onOnboarding) { + return { status: "redirect", to: getLandingPath(user) }; + } + if (!user.hasCompletedOnboarding && onOnboarding) { + return { status: "onboarding" }; + } + } + + return { status: "ready" }; +} diff --git a/frontend/apps/shell/app/features/shell-layout/index.ts b/frontend/apps/shell/app/features/shell-layout/index.ts new file mode 100644 index 00000000..6ca2b0bc --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/index.ts @@ -0,0 +1,16 @@ +export { Navbar } from "./Navbar"; +export { DesktopNav } from "./DesktopNav"; +export { MobileNav } from "./MobileNav"; +export { ReturnToAdminButton } from "./ReturnToAdminButton"; +export { LogExpenseFab } from "./LogExpenseFab"; +export { Forbidden } from "./Forbidden"; +export { navLinksFor } from "./nav-links"; +export { useAuthLayoutGuards } from "./hooks/useAuthLayoutGuards"; +export type { + NavLinkItem, + AuthLayoutGuard, + NavbarProps, + DesktopNavProps, + MobileNavProps, + ReturnToAdminButtonProps, +} from "./types"; diff --git a/frontend/apps/shell/app/features/shell-layout/nav-links.ts b/frontend/apps/shell/app/features/shell-layout/nav-links.ts new file mode 100644 index 00000000..f4a84089 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/nav-links.ts @@ -0,0 +1,29 @@ +import { + LayoutDashboard, + Receipt, + History, + Settings, + Shield, +} from "lucide-react"; +import type { NavLinkItem } from "./types"; + +/** + * navLinksFor returns the navbar entries for the current identity. A direct + * admin (operator, not assuming) gets the operator surface; a regular user or + * an assumed session gets the finance surface. Pure and testable: the caller + * decides who is a direct admin. + */ +export function navLinksFor(isDirectAdmin: boolean): NavLinkItem[] { + if (isDirectAdmin) { + return [ + { to: "/admin", label: "Admin", icon: Shield }, + { to: "/settings", label: "Settings", icon: Settings }, + ]; + } + return [ + { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { to: "/expenses", label: "Expenses", icon: Receipt }, + { to: "/history", label: "History", icon: History }, + { to: "/settings", label: "Settings", icon: Settings }, + ]; +} diff --git a/frontend/apps/shell/app/features/shell-layout/types.ts b/frontend/apps/shell/app/features/shell-layout/types.ts new file mode 100644 index 00000000..58822a7e --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/types.ts @@ -0,0 +1,48 @@ +import type { LucideIcon } from "lucide-react"; +import type { User } from "@gofin/core"; + +/** A single navbar entry (desktop and mobile share the same set). */ +export interface NavLinkItem { + to: string; + label: string; + icon: LucideIcon; +} + +/** + * Outcome of the auth-layout guard, derived from auth state and the matched + * route's `handle.access`. The orchestrator renders each status: + * - `loading`: initial auth check in flight, + * - `redirect`: navigate elsewhere (unauthenticated -> /login, onboarding flow), + * - `forbidden`: render the 403 page inside the layout chrome, + * - `onboarding`: render the onboarding outlet without chrome, + * - `ready`: render the full layout and the routed page. + */ +export type AuthLayoutGuard = + | { status: "loading" } + | { status: "redirect"; to: string } + | { status: "forbidden" } + | { status: "onboarding" } + | { status: "ready" }; + +export interface NavbarProps { + user: User; + isDirectAdmin: boolean; + onLogout: () => void; +} + +export interface DesktopNavProps { + navLinks: NavLinkItem[]; +} + +export interface MobileNavProps { + navLinks: NavLinkItem[]; + user: User; + open: boolean; + onNavigate: () => void; + onLogout: () => void; +} + +export interface ReturnToAdminButtonProps { + onReturn: () => void; + disabled: boolean; +} diff --git a/frontend/apps/shell/app/lib/route-access.ts b/frontend/apps/shell/app/lib/route-access.ts new file mode 100644 index 00000000..c8fa07f4 --- /dev/null +++ b/frontend/apps/shell/app/lib/route-access.ts @@ -0,0 +1,40 @@ +import { canUseAdminFeatures, canUseFinanceFeatures, type User } from "@gofin/core"; + +/** + * Access level attached to a route via its `handle`. It mirrors the backend + * access vocabulary so the shell guard derives behavior from route metadata + * instead of a hardcoded route list: + * + * - `public`: reachable by anyone (login/register live outside the auth layout). + * - `authenticated`: any signed-in identity, no role check. + * - `personal`: a regular user's finance surface (assumed sessions carry + * role=user, so they pass). + * - `admin`: an operator acting directly (not while assuming a user). + */ +export type RouteAccess = "public" | "authenticated" | "personal" | "admin"; + +/** + * canAccess is the single predicate the auth-layout guard applies to a route's + * `handle.access`. It generalizes the old FINANCE_ROUTES check into one rule + * built on the shared role helpers, so adding a new access level never means + * editing guard branches: + * + * - a direct admin fails `personal` (403 instead of a silent redirect), + * - a regular user fails `admin`, + * - an assumed session (role=user) passes `personal` and fails `admin`. + */ +export function canAccess( + user: User, + isAssuming: boolean, + access: RouteAccess, +): boolean { + switch (access) { + case "public": + case "authenticated": + return true; + case "personal": + return canUseFinanceFeatures(user); + case "admin": + return canUseAdminFeatures(user) && !isAssuming; + } +} diff --git a/frontend/apps/shell/app/routes/admin-users.tsx b/frontend/apps/shell/app/routes/admin-users.tsx index bf33686a..5929b39f 100644 --- a/frontend/apps/shell/app/routes/admin-users.tsx +++ b/frontend/apps/shell/app/routes/admin-users.tsx @@ -4,6 +4,8 @@ import { Navigate } from "react-router"; * /admin/users redirects to /admin which contains the full admin panel. * The admin panel page handles user list display. */ +export const handle = { access: "admin" as const }; + export default function AdminUsersPage() { return <Navigate to="/admin" replace />; } diff --git a/frontend/apps/shell/app/routes/admin.tsx b/frontend/apps/shell/app/routes/admin.tsx index 5aba4055..702f5aec 100644 --- a/frontend/apps/shell/app/routes/admin.tsx +++ b/frontend/apps/shell/app/routes/admin.tsx @@ -58,6 +58,8 @@ function AdminSkeleton() { ); } +export const handle = { access: "admin" as const }; + export default function AdminPage() { const { user, isAdmin, isLoading, assumeIdentity } = useAuthStore(); const navigate = useNavigate(); diff --git a/frontend/apps/shell/app/routes/auth-layout.tsx b/frontend/apps/shell/app/routes/auth-layout.tsx index a986e440..da11becc 100644 --- a/frontend/apps/shell/app/routes/auth-layout.tsx +++ b/frontend/apps/shell/app/routes/auth-layout.tsx @@ -1,34 +1,21 @@ -import { Outlet, NavLink, useNavigate, useLocation } from "react-router"; -import { useAuthStore } from "@/stores/auth-store"; -import { canUseAdminFeatures, getLandingPath } from "@gofin/core"; +import { Outlet, Navigate, useNavigate, useLocation } from "react-router"; import { useEffect, useState } from "react"; -import { Button } from "@gofin/ui/components/button"; +import { useAuthStore } from "@/stores/auth-store"; +import { canUseAdminFeatures } from "@gofin/core"; import { - LayoutDashboard, - Receipt, - History, - Settings, - LogOut, - Menu, - X, - Shield, - ArrowLeftToLine, - PlusCircle, -} from "lucide-react"; + Navbar, + ReturnToAdminButton, + LogExpenseFab, + Forbidden, + useAuthLayoutGuards, +} from "@/features/shell-layout"; /** - * Personal-finance routes an operator (direct admin) must never land on. The - * direct-admin guard bounces these to /admin; the gateway 403 is the real - * enforcement, this guard is defense-in-depth to avoid a flash of finance UI. + * AuthLayout is the thin orchestrator for every authenticated route. It reads + * auth state, delegates the routing decision to useAuthLayoutGuards (which + * derives behavior from the matched route's handle.access), and composes the + * shell chrome. Guard logic and presentation live in features/shell-layout. */ -const FINANCE_ROUTES = [ - "/dashboard", - "/expenses", - "/expenses/new", - "/history", - "/onboarding", -]; - export default function AuthLayout() { const { user, @@ -40,48 +27,21 @@ export default function AuthLayout() { restoreIdentity, } = useAuthStore(); const navigate = useNavigate(); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const location = useLocation(); const [isRestoring, setIsRestoring] = useState(false); useEffect(() => { checkAuth(); }, [checkAuth]); - useEffect(() => { - if (!isLoading && !isAuthenticated) { - navigate("/login"); - } - }, [isLoading, isAuthenticated, navigate]); - - // Redirect guards (precedence: direct-admin guard first, then onboarding). - const location = useLocation(); - const isOnOnboarding = location.pathname === "/onboarding"; - - useEffect(() => { - if (isLoading || !isAuthenticated || !user) return; - - const path = location.pathname; - - // 1. Direct-admin guard (takes precedence): an operator is never routed to - // a finance route or /onboarding. Skipped while assuming a user. - if ( - canUseAdminFeatures(user) && - !isAssuming && - FINANCE_ROUTES.includes(path) - ) { - navigate("/admin"); - return; - } - - // 2. Onboarding guard (unchanged for users). - if (!user.hasCompletedOnboarding && path !== "/onboarding") { - navigate("/onboarding"); - } else if (user.hasCompletedOnboarding && path === "/onboarding") { - navigate(getLandingPath(user)); - } - }, [isLoading, isAuthenticated, user, isAssuming, location.pathname, navigate]); + const guard = useAuthLayoutGuards({ + user, + isAuthenticated, + isAssuming, + isLoading, + }); - if (isLoading) { + if (guard.status === "loading") { return ( <div className="flex min-h-screen items-center justify-center"> <div className="text-muted-foreground">Loading...</div> @@ -89,15 +49,23 @@ export default function AuthLayout() { ); } - if (!isAuthenticated || !user) { - return null; + if (guard.status === "redirect") { + return <Navigate to={guard.to} replace />; } - // Render onboarding page without the nav chrome - if (!user.hasCompletedOnboarding && isOnOnboarding) { + // Onboarding renders without chrome so the flow fills the screen. + if (guard.status === "onboarding") { return <Outlet />; } + // forbidden and ready both render the full chrome; the guard guarantees a + // user is present past the redirect branch. + if (!user) { + return null; + } + + const isDirectAdmin = canUseAdminFeatures(user) && !isAssuming; + const handleLogout = async () => { await logout(); navigate("/login"); @@ -113,164 +81,33 @@ export default function AuthLayout() { } }; - // A direct admin (operator, not assuming) gets an operator-only navbar and no - // Log Expense FAB. A regular user or an assumed session keeps the finance nav. - const isDirectAdmin = canUseAdminFeatures(user) && !isAssuming; - - const navLinks = isDirectAdmin - ? [ - { to: "/admin", label: "Admin", icon: Shield }, - { to: "/settings", label: "Settings", icon: Settings }, - ] - : [ - { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, - { to: "/expenses", label: "Expenses", icon: Receipt }, - { to: "/history", label: "History", icon: History }, - { to: "/settings", label: "Settings", icon: Settings }, - ]; - - // FAB is hidden for a direct admin and, as today, while assuming (the Return - // to Admin control occupies the same corner) and on the new-expense page. + // FAB is for finance users only, hidden while assuming (shares the corner + // with Return to Admin), on a 403 page, and on the new-expense page itself. const showLogExpenseFab = - !isDirectAdmin && !isAssuming && location.pathname !== "/expenses/new"; + guard.status === "ready" && + !isDirectAdmin && + !isAssuming && + location.pathname !== "/expenses/new"; return ( <div className="min-h-screen bg-background"> - {/* Navbar */} - <header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> - <div className="mx-auto flex h-14 max-w-7xl items-center px-4"> - {/* Logo */} - <NavLink - to={getLandingPath(user)} - className="mr-6 flex items-center gap-2" - > - <span className="text-lg font-bold">GoFin</span> - </NavLink> - - {/* Desktop nav links */} - <nav className="hidden items-center gap-1 md:flex"> - {navLinks.map((link) => ( - <NavLink - key={link.to} - to={link.to} - className={({ isActive }) => - `flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ - isActive - ? "bg-muted text-foreground" - : "text-muted-foreground hover:bg-muted hover:text-foreground" - }` - } - > - <link.icon className="size-4" /> - {link.label} - </NavLink> - ))} - </nav> - - {/* Spacer */} - <div className="flex-1" /> + <Navbar + user={user} + isDirectAdmin={isDirectAdmin} + onLogout={handleLogout} + /> - {/* User menu (desktop) */} - <div className="hidden items-center gap-3 md:flex"> - <span className="text-sm text-muted-foreground"> - {user.username} - </span> - <Button variant="ghost" size="sm" onClick={handleLogout}> - <LogOut className="size-4" /> - Logout - </Button> - </div> - - {/* Mobile hamburger */} - <Button - variant="ghost" - size="icon" - className="md:hidden" - onClick={() => setMobileMenuOpen(!mobileMenuOpen)} - aria-label={mobileMenuOpen ? "Close menu" : "Open menu"} - > - {mobileMenuOpen ? ( - <X className="size-5" /> - ) : ( - <Menu className="size-5" /> - )} - </Button> - </div> - - {/* Mobile menu */} - {mobileMenuOpen && ( - <nav className="border-t px-4 pb-4 pt-2 md:hidden"> - <div className="flex flex-col gap-1"> - {navLinks.map((link) => ( - <NavLink - key={link.to} - to={link.to} - onClick={() => setMobileMenuOpen(false)} - className={({ isActive }) => - `flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${ - isActive - ? "bg-muted text-foreground" - : "text-muted-foreground hover:bg-muted hover:text-foreground" - }` - } - > - <link.icon className="size-4" /> - {link.label} - </NavLink> - ))} - <div className="mt-2 border-t pt-2"> - <div className="px-3 py-1 text-sm text-muted-foreground"> - {user.username} - </div> - <button - onClick={handleLogout} - className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - > - <LogOut className="size-4" /> - Logout - </button> - </div> - </div> - </nav> - )} - </header> - - {/* Floating "Return to Admin" button during identity assumption */} {isAssuming && ( - <div className="fixed bottom-6 right-6 z-50"> - <Button - variant="default" - size="lg" - onClick={handleReturnToAdmin} - disabled={isRestoring} - className="shadow-lg" - > - <ArrowLeftToLine className="size-4" /> - Return to Admin - </Button> - </div> + <ReturnToAdminButton + onReturn={handleReturnToAdmin} + disabled={isRestoring} + /> )} - {/* Mobile floating "Log Expense" FAB: visible on mobile, hidden when - already on the new-expense page or when the admin return button is - visible (to avoid overlap). */} - {showLogExpenseFab && ( - <div className="fixed bottom-6 right-6 z-40 md:hidden"> - <NavLink to="/expenses/new"> - <Button - size="lg" - className="rounded-full shadow-lg size-14" - aria-label="Log Expense" - > - <PlusCircle className="size-6" /> - </Button> - </NavLink> - </div> - )} + {showLogExpenseFab && <LogExpenseFab />} - {/* Page content */} <main className="mx-auto max-w-7xl px-4 py-6"> - <Outlet /> + {guard.status === "forbidden" ? <Forbidden /> : <Outlet />} </main> </div> ); diff --git a/frontend/apps/shell/app/routes/dashboard.tsx b/frontend/apps/shell/app/routes/dashboard.tsx index f2f0d069..7be0318d 100644 --- a/frontend/apps/shell/app/routes/dashboard.tsx +++ b/frontend/apps/shell/app/routes/dashboard.tsx @@ -14,6 +14,8 @@ const DashboardFeature = lazy(() => })), ); +export const handle = { access: "personal" as const }; + export default function DashboardRoute() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/expenses-new.tsx b/frontend/apps/shell/app/routes/expenses-new.tsx index 0dc23bf5..5810e0ed 100644 --- a/frontend/apps/shell/app/routes/expenses-new.tsx +++ b/frontend/apps/shell/app/routes/expenses-new.tsx @@ -38,6 +38,8 @@ function ExpenseFormSkeleton() { ); } +export const handle = { access: "personal" as const }; + export default function NewExpenseRoute() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/expenses.tsx b/frontend/apps/shell/app/routes/expenses.tsx index affde1e2..4c88e549 100644 --- a/frontend/apps/shell/app/routes/expenses.tsx +++ b/frontend/apps/shell/app/routes/expenses.tsx @@ -12,6 +12,8 @@ const ExpenseLogFeature = lazy(() => })), ); +export const handle = { access: "personal" as const }; + export default function ExpensesRoute() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/history.tsx b/frontend/apps/shell/app/routes/history.tsx index f77bc3f7..78e7c9b8 100644 --- a/frontend/apps/shell/app/routes/history.tsx +++ b/frontend/apps/shell/app/routes/history.tsx @@ -28,6 +28,8 @@ function HistoryLoadingSkeleton() { ); } +export const handle = { access: "personal" as const }; + export default function HistoryRoute() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/home.tsx b/frontend/apps/shell/app/routes/home.tsx index 2d07ba0b..57373c27 100644 --- a/frontend/apps/shell/app/routes/home.tsx +++ b/frontend/apps/shell/app/routes/home.tsx @@ -1,6 +1,12 @@ import { Navigate } from "react-router"; +import { getLandingPath } from "@gofin/core"; +import { useAuthStore } from "@/stores/auth-store"; + +/** Root index route: send each identity to its role-aware landing path. */ +export const handle = { access: "authenticated" as const }; -/** Root index route: redirect to /dashboard. */ export default function HomePage() { - return <Navigate to="/dashboard" replace />; + const { user } = useAuthStore(); + if (!user) return null; + return <Navigate to={getLandingPath(user)} replace />; } diff --git a/frontend/apps/shell/app/routes/onboarding.tsx b/frontend/apps/shell/app/routes/onboarding.tsx index e1a6459b..bc44ad32 100644 --- a/frontend/apps/shell/app/routes/onboarding.tsx +++ b/frontend/apps/shell/app/routes/onboarding.tsx @@ -1,5 +1,7 @@ import { OnboardingFeature } from "@/features/onboarding"; +export const handle = { access: "personal" as const }; + export default function OnboardingRoute() { return <OnboardingFeature />; } diff --git a/frontend/apps/shell/app/routes/settings.tsx b/frontend/apps/shell/app/routes/settings.tsx index 84719c2e..c1b46e2f 100644 --- a/frontend/apps/shell/app/routes/settings.tsx +++ b/frontend/apps/shell/app/routes/settings.tsx @@ -12,6 +12,8 @@ const SettingsPage = lazy(() => })), ); +export const handle = { access: "authenticated" as const }; + export default function SettingsRoute() { const { user, checkAuth } = useAuthStore(); diff --git a/frontend/apps/shell/eslint.config.js b/frontend/apps/shell/eslint.config.js index 1a64d950..b931a44d 100644 --- a/frontend/apps/shell/eslint.config.js +++ b/frontend/apps/shell/eslint.config.js @@ -1 +1,16 @@ -export { default } from "@gofin/config/eslint"; +import config from "@gofin/config/eslint"; + +export default [ + ...config, + { + // React Router route modules export route metadata (`handle`, and may export + // `loader`/`meta`/`action`) alongside their component. That is the + // framework's convention, and HMR for route modules is handled by the React + // Router dev plugin, so the generic react-refresh component-only rule does + // not apply to these files. + files: ["app/routes/**/*.tsx"], + rules: { + "react-refresh/only-export-components": "off", + }, + }, +]; From b33937ccdf5ed0704af06abd374887770c7dd62a Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 02:55:04 +0100 Subject: [PATCH 63/70] test(shell): migrate auth tests to userEvent and 403 guard model - 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 --- .../shell/app/__tests__/auth-layout.test.tsx | 381 +++++++----------- .../features/auth/__tests__/login.test.tsx | 65 +-- .../features/auth/__tests__/register.test.tsx | 75 ++-- 3 files changed, 226 insertions(+), 295 deletions(-) diff --git a/frontend/apps/shell/app/__tests__/auth-layout.test.tsx b/frontend/apps/shell/app/__tests__/auth-layout.test.tsx index 74ad54ee..94c6f2cb 100644 --- a/frontend/apps/shell/app/__tests__/auth-layout.test.tsx +++ b/frontend/apps/shell/app/__tests__/auth-layout.test.tsx @@ -1,8 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { createMemoryRouter, RouterProvider } from "react-router"; +import { + createMemoryRouter, + RouterProvider, + type RouteObject, +} from "react-router"; import { useAuthStore } from "@/stores/auth-store"; +import type { RouteAccess } from "@/lib/route-access"; const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -42,10 +47,36 @@ async function importAuthLayout() { return mod.default; } -describe("AuthLayout - mobile menu and actions", () => { +/** + * Build a router whose auth-layout child carries a `handle.access`, mirroring + * how the real route modules export their access metadata. useMatches() surfaces + * the deepest handle, which is what the guard reads (Checkpoint 3). + */ +function layoutRoute( + AuthLayout: React.ComponentType, + path: string, + access: RouteAccess, + content: string, +): RouteObject { + return { + path, + element: <AuthLayout />, + children: [{ index: true, element: <div>{content}</div>, handle: { access } }], + }; +} + +const destinationRoutes: RouteObject[] = [ + { path: "/login", element: <div>Login redirect target</div> }, +]; + +function renderRouter(routes: RouteObject[], initialPath: string) { + const router = createMemoryRouter(routes, { initialEntries: [initialPath] }); + return render(<RouterProvider router={router} />); +} + +describe("AuthLayout - navbar, actions, and access guard", () => { beforeEach(() => { mockFetch.mockReset(); - // Default: checkAuth returns the authenticated user so the layout renders mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -54,84 +85,48 @@ describe("AuthLayout - mobile menu and actions", () => { }); it("toggles mobile menu on hamburger click", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - user: authenticatedUser, - }); + resetStore({ isAuthenticated: true, user: authenticatedUser }); const AuthLayout = await importAuthLayout(); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/dashboard", ); - render(<RouterProvider router={router} />); - - // Mobile hamburger button should be present - const menuButton = screen.getByLabelText("Open menu"); - await userEvent.click(menuButton); - // Mobile menu should show nav links - // After click, the aria-label changes to "Close menu" + await userEvent.click(screen.getByLabelText("Open menu")); expect(screen.getByLabelText("Close menu")).toBeInTheDocument(); }); it("closes mobile menu when a nav link is clicked", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - user: authenticatedUser, - }); + resetStore({ isAuthenticated: true, user: authenticatedUser }); const AuthLayout = await importAuthLayout(); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { - path: "/expenses", - element: <AuthLayout />, - children: [{ index: true, element: <div>Expenses content</div> }], - }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + layoutRoute(AuthLayout, "/expenses", "personal", "Expenses content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/dashboard", ); - render(<RouterProvider router={router} />); - // Open mobile menu await userEvent.click(screen.getByLabelText("Open menu")); - // Click a nav link in the mobile menu (there are multiple "Expenses" links: desktop + mobile) const expenseLinks = screen.getAllByText("Expenses"); - // Click the last one (mobile menu link) await userEvent.click(expenseLinks[expenseLinks.length - 1]); - // Mobile menu should close await waitFor(() => { expect(screen.getByLabelText("Open menu")).toBeInTheDocument(); }); }); it("performs logout and navigates to /login", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - user: authenticatedUser, - }); + resetStore({ isAuthenticated: true, user: authenticatedUser }); const AuthLayout = await importAuthLayout(); - // First call is checkAuth (returns user), subsequent call is logout mockFetch .mockResolvedValueOnce({ ok: true, @@ -144,34 +139,23 @@ describe("AuthLayout - mobile menu and actions", () => { json: () => Promise.resolve({}), }); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/login", element: <div>Login page</div> }, + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/dashboard", ); - render(<RouterProvider router={router} />); - // Click logout button (desktop) await userEvent.click(screen.getByText("Logout")); await waitFor(() => { - expect(screen.getByText("Login page")).toBeInTheDocument(); + expect(screen.getByText("Login redirect target")).toBeInTheDocument(); }); }); - it("redirects a direct admin off a finance route to /admin", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - isAdmin: true, - user: adminUser, - }); + it("renders a 403 page for a direct admin on a personal route (no redirect, no finance UI flash)", async () => { + resetStore({ isAuthenticated: true, isAdmin: true, user: adminUser }); mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -179,35 +163,41 @@ describe("AuthLayout - mobile menu and actions", () => { }); const AuthLayout = await importAuthLayout(); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/admin", element: <div>Admin page</div> }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/dashboard", ); - render(<RouterProvider router={router} />); - await waitFor(() => { - expect(screen.getByText("Admin page")).toBeInTheDocument(); - }); + expect(await screen.findByText("403: Access denied")).toBeInTheDocument(); + // No silent redirect to /admin and no flash of the finance page content. expect(screen.queryByText("Dashboard content")).not.toBeInTheDocument(); + expect(screen.queryByText("Admin page")).not.toBeInTheDocument(); + // Chrome is kept so the operator can navigate away. + expect(screen.getByText("GoFin")).toBeInTheDocument(); + }); + + it("renders a 403 page for a regular user on an admin route (symmetric guard)", async () => { + resetStore({ isAuthenticated: true, user: authenticatedUser }); + const AuthLayout = await importAuthLayout(); + + renderRouter( + [ + layoutRoute(AuthLayout, "/admin", "admin", "Admin content"), + ...destinationRoutes, + ], + "/admin", + ); + + expect(await screen.findByText("403: Access denied")).toBeInTheDocument(); + expect(screen.queryByText("Admin content")).not.toBeInTheDocument(); }); - it("runs the admin guard before the onboarding guard", async () => { - // An operator that is somehow not onboarded still lands on /admin, never /onboarding. + it("renders a 403 for a direct admin on /onboarding (personal), never the onboarding outlet", async () => { const unonboardedAdmin = { ...adminUser, hasCompletedOnboarding: false }; - resetStore({ - isLoading: false, - isAuthenticated: true, - isAdmin: true, - user: unonboardedAdmin, - }); + resetStore({ isAuthenticated: true, isAdmin: true, user: unonboardedAdmin }); mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -215,74 +205,60 @@ describe("AuthLayout - mobile menu and actions", () => { }); const AuthLayout = await importAuthLayout(); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/admin", element: <div>Admin page</div> }, - { - path: "/onboarding", - element: <AuthLayout />, - children: [{ index: true, element: <div>Onboarding page</div> }], - }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/onboarding", "personal", "Onboarding content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/onboarding", ); - render(<RouterProvider router={router} />); - await waitFor(() => { - expect(screen.getByText("Admin page")).toBeInTheDocument(); - }); - expect(screen.queryByText("Onboarding page")).not.toBeInTheDocument(); + expect(await screen.findByText("403: Access denied")).toBeInTheDocument(); + expect(screen.queryByText("Onboarding content")).not.toBeInTheDocument(); }); - it("does not run the admin guard while assuming a user", async () => { - // Even when the stored user is an admin, an assumed session must not redirect. - resetStore({ - isLoading: false, - isAuthenticated: true, - isAdmin: true, - isAssuming: true, - user: adminUser, - }); + it("never routes an admin to onboarding: unonboarded admin renders the admin route", async () => { + const unonboardedAdmin = { ...adminUser, hasCompletedOnboarding: false }; + resetStore({ isAuthenticated: true, isAdmin: true, user: unonboardedAdmin }); mockFetch.mockResolvedValue({ ok: true, status: 200, - json: () => Promise.resolve({ user: adminUser }), + json: () => Promise.resolve({ user: unonboardedAdmin }), }); const AuthLayout = await importAuthLayout(); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/admin", element: <div>Admin page</div> }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/admin", "admin", "Admin content"), + ...destinationRoutes, + ], + "/admin", + ); + + expect(await screen.findByText("Admin content")).toBeInTheDocument(); + expect(screen.queryByText("Onboarding page")).not.toBeInTheDocument(); + expect(screen.queryByText("403: Access denied")).not.toBeInTheDocument(); + }); + + it("lets an assumed session (role=user) pass a personal route and shows Return to Admin", async () => { + resetStore({ isAuthenticated: true, isAssuming: true, user: authenticatedUser }); + const AuthLayout = await importAuthLayout(); + + renderRouter( + [ + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/dashboard", ); - render(<RouterProvider router={router} />); - // Stays on the finance route; Return to Admin is shown instead of a redirect. expect(await screen.findByText("Dashboard content")).toBeInTheDocument(); expect(screen.getByText("Return to Admin")).toBeInTheDocument(); - expect(screen.queryByText("Admin page")).not.toBeInTheDocument(); + expect(screen.queryByText("403: Access denied")).not.toBeInTheDocument(); }); it("shows only Admin and Settings nav for a direct admin (no finance links, no FAB)", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - isAdmin: true, - user: adminUser, - }); + resetStore({ isAuthenticated: true, isAdmin: true, user: adminUser }); mockFetch.mockResolvedValue({ ok: true, status: 200, @@ -290,20 +266,13 @@ describe("AuthLayout - mobile menu and actions", () => { }); const AuthLayout = await importAuthLayout(); - // Mount on a non-finance route so the direct-admin guard does not redirect. - const router = createMemoryRouter( + renderRouter( [ - { - path: "/settings", - element: <AuthLayout />, - children: [{ index: true, element: <div>Settings content</div> }], - }, - { path: "/admin", element: <div>Admin page</div> }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/settings", "authenticated", "Settings content"), + ...destinationRoutes, ], - { initialEntries: ["/settings"] }, + "/settings", ); - render(<RouterProvider router={router} />); expect(await screen.findByText("Admin")).toBeInTheDocument(); expect(screen.getByText("Settings")).toBeInTheDocument(); @@ -312,7 +281,6 @@ describe("AuthLayout - mobile menu and actions", () => { expect(screen.queryByText("History")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Log Expense")).not.toBeInTheDocument(); - // Logo targets getLandingPath(admin) === /admin. expect(screen.getByText("GoFin").closest("a")).toHaveAttribute( "href", "/admin", @@ -320,80 +288,34 @@ describe("AuthLayout - mobile menu and actions", () => { }); it("keeps full user nav plus Return to Admin for an assumed session", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - isAssuming: true, - user: authenticatedUser, - }); + resetStore({ isAuthenticated: true, isAssuming: true, user: authenticatedUser }); const AuthLayout = await importAuthLayout(); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/admin", element: <div>Admin page</div> }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/dashboard", ); - render(<RouterProvider router={router} />); expect(await screen.findByText("Dashboard")).toBeInTheDocument(); expect(screen.getByText("Expenses")).toBeInTheDocument(); expect(screen.getByText("History")).toBeInTheDocument(); expect(screen.getByText("Settings")).toBeInTheDocument(); expect(screen.getByText("Return to Admin")).toBeInTheDocument(); - // No operator Admin link while acting as a regular user. expect(screen.queryByText("Admin")).not.toBeInTheDocument(); - // FAB stays hidden during assumption (shares the corner with Return to Admin). expect(screen.queryByLabelText("Log Expense")).not.toBeInTheDocument(); - // Logo targets getLandingPath(user) === /dashboard. expect(screen.getByText("GoFin").closest("a")).toHaveAttribute( "href", "/dashboard", ); }); - it("shows Return to Admin button when assuming identity", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - isAssuming: true, - user: authenticatedUser, - }); - const AuthLayout = await importAuthLayout(); - - const router = createMemoryRouter( - [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/login", element: <div>Login redirect target</div> }, - { path: "/admin", element: <div>Admin page</div> }, - ], - { initialEntries: ["/dashboard"] }, - ); - render(<RouterProvider router={router} />); - - expect(screen.getByText("Return to Admin")).toBeInTheDocument(); - }); - it("restores identity and navigates to /admin on Return to Admin click", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - isAssuming: true, - user: authenticatedUser, - }); + resetStore({ isAuthenticated: true, isAssuming: true, user: authenticatedUser }); const AuthLayout = await importAuthLayout(); - // checkAuth returns assumed user, then restoreIdentity returns admin mockFetch .mockResolvedValueOnce({ ok: true, @@ -406,19 +328,14 @@ describe("AuthLayout - mobile menu and actions", () => { json: () => Promise.resolve({ user: adminUser }), }); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + ...destinationRoutes, { path: "/admin", element: <div>Admin page</div> }, ], - { initialEntries: ["/dashboard"] }, + "/dashboard", ); - render(<RouterProvider router={router} />); await waitFor(() => { expect(screen.getByText("Return to Admin")).toBeInTheDocument(); @@ -432,51 +349,33 @@ describe("AuthLayout - mobile menu and actions", () => { }); it("does not show mobile FAB on /expenses/new route", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - user: authenticatedUser, - }); + resetStore({ isAuthenticated: true, user: authenticatedUser }); const AuthLayout = await importAuthLayout(); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/expenses/new", - element: <AuthLayout />, - children: [{ index: true, element: <div>New expense form</div> }], - }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/expenses/new", "personal", "New expense form"), + ...destinationRoutes, ], - { initialEntries: ["/expenses/new"] }, + "/expenses/new", ); - render(<RouterProvider router={router} />); - // The "Log Expense" FAB should not appear on the new expense page itself + expect(await screen.findByText("New expense form")).toBeInTheDocument(); expect(screen.queryByLabelText("Log Expense")).not.toBeInTheDocument(); }); it("shows mobile Log Expense FAB on other pages", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - user: authenticatedUser, - }); + resetStore({ isAuthenticated: true, user: authenticatedUser }); const AuthLayout = await importAuthLayout(); - const router = createMemoryRouter( + renderRouter( [ - { - path: "/dashboard", - element: <AuthLayout />, - children: [{ index: true, element: <div>Dashboard content</div> }], - }, - { path: "/login", element: <div>Login redirect target</div> }, + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/dashboard", ); - render(<RouterProvider router={router} />); - expect(screen.getByLabelText("Log Expense")).toBeInTheDocument(); + expect(await screen.findByLabelText("Log Expense")).toBeInTheDocument(); }); }); diff --git a/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx b/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx index 8dbf5497..3c8a03a4 100644 --- a/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx +++ b/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { buildUser, createMockApi, renderWithRouter } from "@gofin/test-utils"; import { useAuthStore } from "@/stores/auth-store"; @@ -54,6 +55,12 @@ describe("login page", () => { vi.restoreAllMocks(); }); + // These cases submit an empty or malformed form to exercise the submit + // handler's own validation. They use fireEvent.submit to dispatch the submit + // event directly: a real userEvent click would trip the inputs' native + // required/type=email constraints first, so the browser would block + // submission and the handler's messages would never render. Interaction + // tests below use userEvent. describe("form validation", () => { it("shows 'Email is required' when submitting with empty email", async () => { setupUnauthenticatedMock(); @@ -96,14 +103,15 @@ describe("login page", () => { describe("API error handling", () => { it("displays the server error message on wrong credentials", async () => { + const user = userEvent.setup(); setupUnauthenticatedMock({ "/api/auth/login": { status: 401, body: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" } }, }); await renderLoginPage(); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "WrongPass1" } }); - fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + await user.type(screen.getByLabelText("Email"), "user@example.com"); + await user.type(screen.getByLabelText("Password"), "WrongPass1"); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByText("Invalid email or password")).toBeInTheDocument(); @@ -113,15 +121,16 @@ describe("login page", () => { describe("successful login", () => { it("redirects to /dashboard after login", async () => { - const user = buildUser({ hasCompletedOnboarding: true }); + const user = userEvent.setup(); + const loginUser = buildUser({ hasCompletedOnboarding: true }); setupUnauthenticatedMock({ - "/api/auth/login": { user }, + "/api/auth/login": { user: loginUser }, }); await renderLoginPage(); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); - fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + await user.type(screen.getByLabelText("Email"), "user@example.com"); + await user.type(screen.getByLabelText("Password"), "Password1"); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByText("Dashboard page")).toBeInTheDocument(); @@ -129,15 +138,16 @@ describe("login page", () => { }); it("redirects to /admin after an admin logs in", async () => { + const user = userEvent.setup(); const admin = buildUser({ role: "admin", hasCompletedOnboarding: true }); setupUnauthenticatedMock({ "/api/auth/login": { user: admin }, }); await renderLoginPage(); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "admin@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); - fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + await user.type(screen.getByLabelText("Email"), "admin@example.com"); + await user.type(screen.getByLabelText("Password"), "Password1"); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByText("Admin page")).toBeInTheDocument(); @@ -145,15 +155,16 @@ describe("login page", () => { }); it("redirects to /onboarding for non-onboarded user", async () => { - const user = buildUser({ hasCompletedOnboarding: false }); + const user = userEvent.setup(); + const loginUser = buildUser({ hasCompletedOnboarding: false }); setupUnauthenticatedMock({ - "/api/auth/login": { user }, + "/api/auth/login": { user: loginUser }, }); await renderLoginPage(); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); - fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + await user.type(screen.getByLabelText("Email"), "user@example.com"); + await user.type(screen.getByLabelText("Password"), "Password1"); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByText("Onboarding page")).toBeInTheDocument(); @@ -174,9 +185,10 @@ describe("login page", () => { describe("consumeReturnToPath redirect", () => { it("redirects to saved path from sessionStorage after login", async () => { - const user = buildUser({ hasCompletedOnboarding: true }); + const user = userEvent.setup(); + const loginUser = buildUser({ hasCompletedOnboarding: true }); setupUnauthenticatedMock({ - "/api/auth/login": { user }, + "/api/auth/login": { user: loginUser }, }); vi.spyOn(Storage.prototype, "getItem").mockImplementation((key: string) => { @@ -187,9 +199,9 @@ describe("login page", () => { await renderLoginPage(); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); - fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + await user.type(screen.getByLabelText("Email"), "user@example.com"); + await user.type(screen.getByLabelText("Password"), "Password1"); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByText("Expenses page")).toBeInTheDocument(); @@ -200,8 +212,9 @@ describe("login page", () => { // Regression guard: after login, both the onSuccess handler (returnTo) and // the getLandingPath effect (/admin for admins) can fire. onSuccess must // win so the admin lands on their saved path, not the landing path. - // /settings is not a FINANCE_ROUTE, so the auth-layout guard would not - // otherwise correct an override back to the intended destination. + // /settings is an "authenticated" route (reachable by an admin), so the + // auth-layout access guard does not bounce the admin off it. + const user = userEvent.setup(); const admin = buildUser({ role: "admin", hasCompletedOnboarding: true }); setupUnauthenticatedMock({ "/api/auth/login": { user: admin }, @@ -215,9 +228,9 @@ describe("login page", () => { await renderLoginPage(); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "admin@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); - fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + await user.type(screen.getByLabelText("Email"), "admin@example.com"); + await user.type(screen.getByLabelText("Password"), "Password1"); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByText("Settings page")).toBeInTheDocument(); diff --git a/frontend/apps/shell/app/features/auth/__tests__/register.test.tsx b/frontend/apps/shell/app/features/auth/__tests__/register.test.tsx index 5352173b..fe967481 100644 --- a/frontend/apps/shell/app/features/auth/__tests__/register.test.tsx +++ b/frontend/apps/shell/app/features/auth/__tests__/register.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { buildUser, createMockApi, renderWithRouter } from "@gofin/test-utils"; import { useAuthStore } from "@/stores/auth-store"; @@ -43,9 +44,29 @@ async function renderRegisterPage() { }); } -function submitForm() { - const form = screen.getByRole("button", { name: "Create account" }).closest("form")!; - fireEvent.submit(form); +function submitButton() { + return screen.getByRole("button", { name: "Create account" }); +} + +/** + * Fill every required field with valid values so a real submit click clears the + * inputs' native required/type=email constraints and reaches the submit handler. + * Callers override individual fields to exercise a specific field-level error. + */ +async function fillValidForm( + user: ReturnType<typeof userEvent.setup>, + overrides: Partial<Record<"username" | "email" | "password", string>> = {}, +) { + const values = { + username: "newuser", + email: "new@example.com", + password: "Password1", + ...overrides, + }; + await user.type(screen.getByLabelText("Username"), values.username); + await user.type(screen.getByLabelText("Email"), values.email); + await user.type(screen.getByLabelText("Password"), values.password); + await user.type(screen.getByLabelText("Confirm Password"), values.password); } describe("register page", () => { @@ -59,7 +80,10 @@ describe("register page", () => { setupUnauthenticatedMock(); await renderRegisterPage(); - submitForm(); + // Submitted empty to exercise the handler's own guard; a real click would + // be blocked by the inputs' native required constraints first. + const form = submitButton().closest("form")!; + fireEvent.submit(form); await waitFor(() => { expect(screen.getByText("Username is required")).toBeInTheDocument(); @@ -67,11 +91,12 @@ describe("register page", () => { }); it("shows 'Username must be at least 2 characters' for short username", async () => { + const user = userEvent.setup(); setupUnauthenticatedMock(); await renderRegisterPage(); - fireEvent.change(screen.getByLabelText("Username"), { target: { value: "a" } }); - submitForm(); + await fillValidForm(user, { username: "a" }); + await user.click(submitButton()); await waitFor(() => { expect(screen.getByText("Username must be at least 2 characters")).toBeInTheDocument(); @@ -79,19 +104,16 @@ describe("register page", () => { }); it("shows password strength error for weak password", async () => { + const user = userEvent.setup(); setupUnauthenticatedMock(); await renderRegisterPage(); - fireEvent.change(screen.getByLabelText("Username"), { target: { value: "validuser" } }); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "weak" } }); - submitForm(); + await fillValidForm(user, { password: "weak" }); + await user.click(submitButton()); await waitFor(() => { expect( - screen.getByText( - "Password must be at least 8 characters", - ), + screen.getByText("Password must be at least 8 characters"), ).toBeInTheDocument(); }); }); @@ -99,6 +121,7 @@ describe("register page", () => { describe("API error handling", () => { it("displays server message when username is already taken", async () => { + const user = userEvent.setup(); setupUnauthenticatedMock({ "/api/auth/register": { status: 409, @@ -107,10 +130,8 @@ describe("register page", () => { }); await renderRegisterPage(); - fireEvent.change(screen.getByLabelText("Username"), { target: { value: "taken" } }); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); - submitForm(); + await fillValidForm(user, { username: "taken" }); + await user.click(submitButton()); await waitFor(() => { expect(screen.getByText("Username is already taken")).toBeInTheDocument(); @@ -121,6 +142,7 @@ describe("register page", () => { }); it("does not navigate when email is already taken", async () => { + const user = userEvent.setup(); setupUnauthenticatedMock({ "/api/auth/register": { status: 409, @@ -129,10 +151,8 @@ describe("register page", () => { }); await renderRegisterPage(); - fireEvent.change(screen.getByLabelText("Username"), { target: { value: "newuser" } }); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "taken@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); - submitForm(); + await fillValidForm(user, { email: "taken@example.com" }); + await user.click(submitButton()); await waitFor(() => { expect(screen.getByText("Email is already in use")).toBeInTheDocument(); @@ -145,16 +165,15 @@ describe("register page", () => { describe("successful registration", () => { it("auto-logs in and redirects to /onboarding", async () => { - const user = buildUser({ hasCompletedOnboarding: false }); + const user = userEvent.setup(); + const newUser = buildUser({ hasCompletedOnboarding: false }); setupUnauthenticatedMock({ - "/api/auth/register": { user }, + "/api/auth/register": { user: newUser }, }); await renderRegisterPage(); - fireEvent.change(screen.getByLabelText("Username"), { target: { value: "newuser" } }); - fireEvent.change(screen.getByLabelText("Email"), { target: { value: "new@example.com" } }); - fireEvent.change(screen.getByLabelText("Password"), { target: { value: "Password1" } }); - submitForm(); + await fillValidForm(user); + await user.click(submitButton()); await waitFor(() => { expect(screen.getByText("Onboarding page")).toBeInTheDocument(); @@ -163,7 +182,7 @@ describe("register page", () => { // Verify user is now in the auth store (auto-logged in) const state = useAuthStore.getState(); expect(state.isAuthenticated).toBe(true); - expect(state.user?.id).toBe(user.id); + expect(state.user?.id).toBe(newUser.id); }); }); }); From b83762431802f65a2034f4285dbebf9b278c5988 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 02:58:52 +0100 Subject: [PATCH 64/70] docs: align access-control docs with the shared registry - 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 --- docs/api.md | 40 ++++++++++++++++++++-------------------- docs/architecture.md | 4 ++-- docs/data-model.md | 4 ++-- docs/development.md | 6 +++--- docs/testing.md | 6 +++--- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/api.md b/docs/api.md index 13d60cea..cae2d8ef 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,26 +36,26 @@ The gateway applies one centralized access-control policy: every route resolves ### Route-Level Access -The canonical policy table (`services/gateway/internal/access/policy.go`) classifies each route: - -| Access | Method | Path | Match | -|--------|--------|------|-------| -| `Public` | POST | `/api/auth/register` | Exact | -| `Public` | POST | `/api/auth/login` | Exact | -| `Public` | POST | `/api/auth/refresh` | Exact | -| `Public` | GET | `/health` | Exact | -| `Public` | GET | `/metrics` | Exact | -| `Admin` | (any) | `/api/admin` | Prefix | -| `Admin` | POST | `/api/auth/assume` | Exact | -| `Admin` | (any) | `/api/datarights/deletions` | Prefix | -| `Personal` | POST | `/api/auth/onboarding-complete` | Exact | -| `Personal` | (any) | `/api/finance` | Prefix | -| `Personal` | (any) | `/api/expenses` | Prefix | -| `Personal` | (any) | `/api/datarights/exports` | Prefix | -| `Authenticated` | (any) | `/api/auth/me` | Prefix | -| `Authenticated` | POST | `/api/auth/logout` | Exact | -| `Authenticated` | POST | `/api/auth/restore` | Exact | -| `Authenticated` (default) | (any) | *(unmatched, e.g. bare `/api/auth`)* | Fallback | +The canonical route registry (`services/access/registry.go`) classifies each route, and the gateway resolves each request to the concrete route gin dispatches. Access levels by route: + +| Access | Method | Path | +|--------|--------|------| +| `Public` | POST | `/api/auth/register` | +| `Public` | POST | `/api/auth/login` | +| `Public` | POST | `/api/auth/refresh` | +| `Public` | GET | `/health` | +| `Public` | GET | `/metrics` | +| `Admin` | GET | `/api/admin/users` | +| `Admin` | POST | `/api/auth/assume` | +| `Admin` | (any) | `/api/datarights/deletions/*` | +| `Personal` | POST | `/api/auth/onboarding-complete` | +| `Personal` | (any) | `/api/finance/*` | +| `Personal` | (any) | `/api/expenses/*` | +| `Personal` | (any) | `/api/datarights/exports/*` | +| `Authenticated` | (any) | `/api/auth/me`, `/api/auth/me/password` | +| `Authenticated` | POST | `/api/auth/logout` | +| `Authenticated` | POST | `/api/auth/restore` | +| `Authenticated` (default) | (any) | *(any path with no registry entry, e.g. bare `/api/auth`)* | The `Personal` routes are `/api/finance/*`, `/api/expenses/*`, `/api/datarights/exports*`, and `POST /api/auth/onboarding-complete`. A direct admin (`role=admin`) receives **403** on all of them; an assumed session carries `role=user` (with an `assumedBy` claim) and passes. `POST /api/auth/restore` is `Authenticated`, so an assumed session can always restore. diff --git a/docs/architecture.md b/docs/architecture.md index 533511fd..07d39f6b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,10 +82,10 @@ The shell owns: ### API Gateway (Node 2) -A lightweight Go/Gin reverse proxy that validates auth and routes requests. A single centralized `AccessControl` middleware backed by an ordered policy table classifies every route into one of four access levels (Public / Authenticated / Personal / Admin) and enforces it: +A lightweight Go/Gin reverse proxy that validates auth and routes requests. A single centralized `AccessControl` middleware backed by the shared `services/access` route registry classifies every route into one of four access levels (Public / Authenticated / Personal / Admin) and enforces it: 1. Strips client-supplied identity headers so they cannot be spoofed -2. Resolves the route's access level from the policy table (exact match first, then longest prefix, else the fail-safe default of `Authenticated`) +2. Resolves the route's access level from the shared registry, matching the concrete route gin will dispatch to (else the fail-safe default of `Authenticated`) 3. `Public` routes pass with no token read (e.g. `/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/health`, `/metrics`) 4. Otherwise verifies the `gofin_access` cookie via Auth Service gRPC `ValidateToken` (401 on failure) and injects `X-User-ID`, `X-User-Role`, and (when assuming) `X-Assumed-By` 5. Enforces the level's role: `Personal` routes require `role == "user"` and `Admin` routes require `role == "admin"` (403 on mismatch) diff --git a/docs/data-model.md b/docs/data-model.md index 4e85029a..03eb1178 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -17,7 +17,7 @@ PostgreSQL runs as a single instance with separate schemas and connection creden Canonical source: `services/datarights/db/migrations/` -> **Operator-only admin:** data export is a `Personal` operation, so `datarights.export_jobs` holds only regular-user rows and never admin-owned rows. The one-time cleanup (linked under Finance Schema) also removed any admin-owned export jobs. +> **Operator-only admin:** data export is a `Personal` operation, so `datarights.export_jobs` holds only regular-user rows and never admin-owned rows. ### `datarights.export_jobs` @@ -74,7 +74,7 @@ Tracks revoked refresh tokens by their `jti` claim. Entries include the token's Canonical source: `services/finance/db/migrations/` -> **Operator-only admin:** `admin` accounts own no rows in any finance table. Admin is an operator identity (authentication, admin panel, identity assumption, user deletion, Grafana) and never goes through the onboarding or budget flows. A one-time change-managed cleanup removed any pre-existing admin-owned finance rows; see [`change-management/001_admin-finance-cleanup`](../change-management/001_admin-finance-cleanup/description.md). Every table below is scoped per `user_id` and only ever holds regular-user (`role=user`) data. +> **Operator-only admin:** `admin` accounts own no rows in any finance table. Admin is an operator identity (authentication, admin panel, identity assumption, user deletion, Grafana) and never goes through the onboarding or budget flows. Every table below is scoped per `user_id` and only ever holds regular-user (`role=user`) data. ### `finance.budget_periods` diff --git a/docs/development.md b/docs/development.md index 2267e04e..e98ab566 100644 --- a/docs/development.md +++ b/docs/development.md @@ -69,7 +69,7 @@ The mock layer provides: - Dashboard aggregation data (summary, pacing, tag spending, cumulative chart, historical comparison) - An upcoming pro-rata installment -Mock data is defined in `frontend/apps/shell/mocks/data.ts`. Request handlers are in `frontend/apps/shell/mocks/handlers.ts`. The default mock user is the operator (admin), who lands on `/admin` and sees only the admin panel plus Settings (Profile and Password); a direct admin is redirected off the personal finance routes, so the budget/expenses/dashboard fixtures are not reachable while acting as the admin. To exercise the personal finance UI, set `currentMockUser` to the regular user (`regularUser`, "alex") in `data.ts`, or log in as the admin and assume that user from the admin panel. +Mock data is defined in `frontend/apps/shell/mocks/data.ts`. Request handlers are in `frontend/apps/shell/mocks/handlers.ts`. The default mock user is the operator (admin), who lands on `/admin` and sees only the admin panel plus Settings (Profile and Password); a direct admin who opens a personal finance route by URL gets a 403 page, so the budget/expenses/dashboard fixtures are not reachable while acting as the admin. To exercise the personal finance UI, set `currentMockUser` to the regular user (`regularUser`, "alex") in `data.ts`, or log in as the admin and assume that user from the admin panel. **How it works:** @@ -169,9 +169,9 @@ The first admin user must be created before the admin panel, identity assumption just seed-admin ``` -This runs the auth service's `seed-admin` CLI subcommand, which reads `ADMIN_USERNAME`, `ADMIN_EMAIL`, and `ADMIN_PASSWORD` from environment variables. The command is idempotent: if an admin already exists, it exits successfully. It also marks the admin's onboarding as complete server-side, so the operator does not go through the finance onboarding flow. +This runs the auth service's `seed-admin` CLI subcommand, which reads `ADMIN_USERNAME`, `ADMIN_EMAIL`, and `ADMIN_PASSWORD` from environment variables. The command is idempotent: if an admin already exists, it exits successfully. It also marks the admin's onboarding complete server-side for data consistency, but that flag is not the exemption mechanism: admins are structurally exempt from onboarding via their role and the route access metadata (see below), not because the flag is set. -The admin is an operator-only identity: it owns no finance data and does not use the onboarding or budget flows (`POST /api/auth/onboarding-complete` is a `Personal` route and returns 403 to a direct admin). After logging in through the normal UI, a direct admin lands on `/admin` and is redirected off the personal finance routes. +The admin is an operator-only identity: it owns no finance data and does not use the onboarding or budget flows (`POST /api/auth/onboarding-complete` is a `Personal` route and returns 403 to a direct admin). After logging in through the normal UI, a direct admin lands on `/admin`; opening a personal finance route by URL renders a 403 page, because the route's access metadata rejects a direct operator. ## Datarights Service diff --git a/docs/testing.md b/docs/testing.md index f77fde50..7488d84d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -104,11 +104,11 @@ func TestExpenseRepository_Integration(t *testing.T) { | Finance: pro-rata scheduling | High | Installment math, remainder handling, year rollover, application at period creation | | Auth: token lifecycle | High | Generation, validation, refresh rotation, blacklisting, `tokens_revoked_at` | | Auth: RBAC | High | Role checking, identity assumption, audit claims | -| Gateway: access policy resolver | High | `resolve` precedence (exact > longest prefix > default), every access level, per-route classification | +| Access registry + resolver (`services/access`) | High | Every `Registry` route resolves to its declared level; unique IDs; gin-priority matching (static > param > wildcard) on real overlaps; unknown/wrong-method paths fall to the `Authenticated` default | | Gateway: `AccessControl` middleware | High | Per-level/per-role outcomes (pass/401/403), direct admin vs assumed `role=user` on Personal routes, header stripping, `X-Assumed-By` forwarding | | Finance: aggregations | Medium | Category sums, tag spending, cumulative spend | | Auth: password handling | Medium | Hashing, verification, strength validation | -| Gateway: route coverage | Medium | Every known route prefix resolves to an explicit (non-default) access level | +| Service route coverage (registry-driven) | Medium | Each service registers routes from the `services/access` Registry; a per-service test asserts `engine.Routes()` matches the Registry both ways, so adding an unclassified route fails that service's own `go test` in CI | ### Frontend @@ -117,7 +117,7 @@ func TestExpenseRepository_Integration(t *testing.T) { | Auth store | High | Login, logout, refresh, assumption state transitions | | Role helpers (`core/roles.ts`) | High | `canUseFinanceFeatures`, `canUseAdminFeatures`, `getLandingPath` truth table across both roles | | Auth flow (login/register) | High | Form validation, error messages, role-based landing (`getLandingPath`) | -| Shell nav + route guards | High | Role-derived nav, direct-admin finance-route guard (redirect to `/admin`), assumed-user nav + Return to Admin | +| Shell nav + route guards | High | Role-derived nav; the route `handle.access` guard renders a 403 page for a direct admin on a personal route (and, symmetrically, a regular user on an admin route); assumed-user nav + Return to Admin | | ExpenseForm | High | Validation, pro-rata toggle, submission | | ExpenseLog (TanStack Table) | Medium | Sorting, filtering, pagination | | Dashboard gauges | Medium | Percentage display, color coding | From b7f76cd3967cc77889c63d920e62bf81119c8a6a Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 03:11:30 +0100 Subject: [PATCH 65/70] docs(access): drop stale prefix wording in comments - 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 --- services/auth/internal/handler/rest.go | 6 ++++-- services/gateway/internal/access/control_test.go | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/services/auth/internal/handler/rest.go b/services/auth/internal/handler/rest.go index 67bbb4a2..caeafbeb 100644 --- a/services/auth/internal/handler/rest.go +++ b/services/auth/internal/handler/rest.go @@ -291,7 +291,8 @@ func (h *RESTHandler) Logout(c *gin.Context) { // ListUsers handles GET /api/admin/users. // Returns all registered users. The gateway enforces admin (operator) access -// via its centralized access-control policy (the /api/admin prefix is Admin-only). +// via its centralized access-control middleware (GET /api/admin/users is +// classified Admin in the shared services/access registry). func (h *RESTHandler) ListUsers(c *gin.Context) { start := time.Now() @@ -323,7 +324,8 @@ func (h *RESTHandler) ListUsers(c *gin.Context) { // AssumeIdentity handles POST /api/auth/assume. // Generates a new JWT for the target user with the assumedBy claim. // The gateway enforces admin (operator) access via its centralized -// access-control policy (POST /api/auth/assume is Admin-only). +// access-control middleware (POST /api/auth/assume is classified Admin in the +// shared services/access registry). func (h *RESTHandler) AssumeIdentity(c *gin.Context) { start := time.Now() diff --git a/services/gateway/internal/access/control_test.go b/services/gateway/internal/access/control_test.go index 41879df6..25fc0afb 100644 --- a/services/gateway/internal/access/control_test.go +++ b/services/gateway/internal/access/control_test.go @@ -255,10 +255,10 @@ func TestAccessControl_AssumedUser_PassesPersonalRoutesAndRestore(t *testing.T) method string path string }{ - {http.MethodPost, "/api/auth/onboarding-complete"}, // Personal (exact) - {http.MethodGet, "/api/finance/periods"}, // Personal (prefix) - {http.MethodPost, "/api/expenses"}, // Personal (prefix) - {http.MethodPost, "/api/datarights/exports"}, // Personal (prefix) + {http.MethodPost, "/api/auth/onboarding-complete"}, // Personal + {http.MethodGet, "/api/finance/periods"}, // Personal + {http.MethodPost, "/api/expenses"}, // Personal + {http.MethodPost, "/api/datarights/exports"}, // Personal {http.MethodPost, "/api/auth/restore"}, // Authenticated } From e353f1afb1c93626524e6a955f27ccc8815a17ea Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 03:41:34 +0100 Subject: [PATCH 66/70] feat(access): deny unclassified routes by default 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 --- services/access/access.go | 7 ++++ services/access/registry.go | 4 +- services/access/registry_test.go | 7 ++-- services/access/resolver.go | 21 ++++++----- services/access/resolver_test.go | 13 ++++--- services/gateway/internal/access/control.go | 37 ++++++++++++++++--- .../gateway/internal/access/control_test.go | 22 ++++++++++- .../gateway/internal/router/router_test.go | 18 +++++---- 8 files changed, 94 insertions(+), 35 deletions(-) diff --git a/services/access/access.go b/services/access/access.go index c6d35d1e..2b76d9d9 100644 --- a/services/access/access.go +++ b/services/access/access.go @@ -24,6 +24,11 @@ const ( Personal // Admin routes require a valid token acting as an operator (role == "admin"). Admin + // Deny is the fail-safe the resolver returns for a path that no Registry + // entry classifies. It is not a level a route may declare: an unclassified + // path is refused (403) rather than reachable, so onboarding a route (or a + // whole new proxied prefix) is dead until it is added to the Registry. + Deny ) // String returns the level name, used for readable logs and test diagnostics. @@ -37,6 +42,8 @@ func (a Access) String() string { return "Personal" case Admin: return "Admin" + case Deny: + return "Deny" default: return "Unknown" } diff --git a/services/access/registry.go b/services/access/registry.go index 526cbd7e..b19cb6c0 100644 --- a/services/access/registry.go +++ b/services/access/registry.go @@ -98,8 +98,8 @@ func RoutesFor(service string) []Route { } // Classifies reports whether method+path is matched by an explicit Registry -// entry, as opposed to falling through to the fail-safe Authenticated default -// in Resolve. Services use it as a defense-in-depth check that every route they +// entry, as opposed to falling through to the fail-safe Deny default in +// Resolve. Services use it as a defense-in-depth check that every route they // register is classified by the Registry. func Classifies(method, path string) bool { for _, r := range Registry { diff --git a/services/access/registry_test.go b/services/access/registry_test.go index 9d398939..c8cfa308 100644 --- a/services/access/registry_test.go +++ b/services/access/registry_test.go @@ -68,7 +68,8 @@ func TestRoutesFor_UnknownServiceIsEmpty(t *testing.T) { // TestClassifies_UnknownRoutesAreUnclassified is the failure-mode guardrail: a // route with no Registry entry, or a real path under the wrong method, is not -// classified, so Resolve returns the fail-safe Authenticated default. +// classified, so Resolve returns the fail-safe Deny default (403 at the +// gateway). func TestClassifies_UnknownRoutesAreUnclassified(t *testing.T) { cases := []struct { name string @@ -86,8 +87,8 @@ func TestClassifies_UnknownRoutesAreUnclassified(t *testing.T) { if Classifies(tc.method, tc.path) { t.Errorf("Classifies(%q, %q) = true, want false", tc.method, tc.path) } - if got := Resolve(tc.method, tc.path); got != Authenticated { - t.Errorf("Resolve(%q, %q) = %s, want Authenticated (fail-safe default)", tc.method, tc.path, got) + if got := Resolve(tc.method, tc.path); got != Deny { + t.Errorf("Resolve(%q, %q) = %s, want Deny (fail-safe default)", tc.method, tc.path, got) } }) } diff --git a/services/access/resolver.go b/services/access/resolver.go index d4cc64e1..b46fcea3 100644 --- a/services/access/resolver.go +++ b/services/access/resolver.go @@ -8,14 +8,17 @@ import "strings" // dispatch to (see moreSpecific), so the gateway classifies exactly the route // that will handle the request. // -// Unmatched requests fall back to Authenticated, the fail-safe default: an -// unclassified path still requires a valid token, and (since it corresponds to -// no real route) 404s downstream. Because every Registry pattern is a concrete -// route with exact static segments, a sibling path like -// "/api/datarights/exports-admin" cannot borrow "/api/datarights/exports"'s -// level: static segments must match byte-for-byte. This makes the -// leading-substring bug that segment-boundary prefix matching guarded against -// (commit ca37e4c) impossible by construction. +// Unmatched requests fall back to Deny, the fail-safe default: a path that no +// Registry entry classifies is denied (403), not allowed. This makes route +// classification self-enforcing across services as well as routes: a new or +// unclassified route (or a whole new proxied prefix) is dead on arrival until +// it is added to the Registry with an access level, extending the existing +// "can't ship an unclassified route" guarantee to new services by +// construction. Because every Registry pattern is a concrete route with exact +// static segments, a sibling path like "/api/datarights/exports-admin" cannot +// borrow "/api/datarights/exports"'s level: static segments must match +// byte-for-byte. This makes the leading-substring bug that segment-boundary +// prefix matching guarded against (commit ca37e4c) impossible by construction. func Resolve(method, path string) Access { var best *Route for i := range Registry { @@ -28,7 +31,7 @@ func Resolve(method, path string) Access { } } if best == nil { - return Authenticated + return Deny } return best.Access } diff --git a/services/access/resolver_test.go b/services/access/resolver_test.go index eb891a42..0c07308f 100644 --- a/services/access/resolver_test.go +++ b/services/access/resolver_test.go @@ -44,10 +44,11 @@ func TestResolve_WorkedExamples(t *testing.T) { } } -// TestResolve_FallsBackToAuthenticated covers every way a request misses the -// Registry: wrong method, sibling substrings that must not borrow a neighbor's -// level, bare groups with no exact route, and wholly unknown paths. -func TestResolve_FallsBackToAuthenticated(t *testing.T) { +// TestResolve_FallsBackToDeny covers every way a request misses the Registry: +// wrong method, sibling substrings that must not borrow a neighbor's level, +// bare groups with no exact route, and wholly unknown paths. Each now resolves +// to the deny-by-default fail-safe (403 at the gateway), not Authenticated. +func TestResolve_FallsBackToDeny(t *testing.T) { cases := []struct { name string method string @@ -69,8 +70,8 @@ func TestResolve_FallsBackToAuthenticated(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := Resolve(tc.method, tc.path); got != Authenticated { - t.Errorf("Resolve(%q, %q) = %s, want Authenticated", tc.method, tc.path, got) + if got := Resolve(tc.method, tc.path); got != Deny { + t.Errorf("Resolve(%q, %q) = %s, want Deny", tc.method, tc.path, got) } }) } diff --git a/services/gateway/internal/access/control.go b/services/gateway/internal/access/control.go index 6616c50d..c438b977 100644 --- a/services/gateway/internal/access/control.go +++ b/services/gateway/internal/access/control.go @@ -41,9 +41,10 @@ const ( // 1. strips the spoofable identity headers, // 2. resolves the route's access level via the injected resolver, // 3. short-circuits Public routes with no token read, -// 4. otherwise validates the gofin_access cookie (401 on missing/invalid), -// 5. injects the validated identity as downstream headers, and -// 6. enforces the per-level role check (403 when the role is wrong). +// 4. short-circuits Deny (an unclassified route) with a 403 and no token read, +// 5. otherwise validates the gofin_access cookie (401 on missing/invalid), +// 6. injects the validated identity as downstream headers, and +// 7. enforces the per-level role check (403 when the role is wrong). // // The per-level switch is fail-safe: only Authenticated passes without a role // check, and any level that is not explicitly allowed is denied (403). @@ -56,6 +57,12 @@ func AccessControl(validator TokenValidator, resolve func(method, path string) a c.Next() return } + if level == access.Deny { + // An unclassified path is not a real route, so no identity is + // needed: refuse it with a 403 before the cookie is read. + abortForbidden(c, logger) + return + } cookie, err := c.Request.Cookie(accessCookie) if err != nil || cookie.Value == "" { @@ -94,9 +101,10 @@ func AccessControl(validator TokenValidator, resolve func(method, path string) a case access.Authenticated: // Any valid token passes; no role check. default: - // Fail-safe by construction: Public is short-circuited before token - // validation, so anything reaching here that is not explicitly - // allowed (including an unrecognized future Access value) is denied. + // Fail-safe by construction: Public and Deny are short-circuited + // before token validation, so anything reaching here that is not + // explicitly allowed (including an unrecognized future Access value) + // is denied. rejectForbidden(c, logger, result) return } @@ -157,6 +165,23 @@ func rejectForbidden(c *gin.Context, logger *slog.Logger, result *TokenValidatio slog.String("role", result.Role), slog.String("user_id", result.UserID), ) + writeForbidden(c) +} + +// abortForbidden ends the request with the same 403 FORBIDDEN/"Access denied" +// contract for a Deny (unclassified) route. No token was read, so there is no +// validated identity to log; only the method and path are recorded. +func abortForbidden(c *gin.Context, logger *slog.Logger) { + logger.Warn("access denied for unclassified route", + slog.String("method", c.Request.Method), + slog.String("path", c.Request.URL.Path), + ) + writeForbidden(c) +} + +// writeForbidden emits the shared 403 body contract (FORBIDDEN / "Access +// denied") used by both the role-denied and unclassified-route paths. +func writeForbidden(c *gin.Context) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "code": "FORBIDDEN", "message": "Access denied", diff --git a/services/gateway/internal/access/control_test.go b/services/gateway/internal/access/control_test.go index 25fc0afb..83e561e0 100644 --- a/services/gateway/internal/access/control_test.go +++ b/services/gateway/internal/access/control_test.go @@ -376,13 +376,31 @@ func TestAccessControl_UnknownLevel_DeniesByDefault(t *testing.T) { assert.Contains(t, rec.Body.String(), "FORBIDDEN") } +// TestAccessControl_Deny_NoCookie_Returns403 proves the deny-by-default +// short-circuit: an unclassified /api path (no Registry entry) resolves to Deny +// via GatewayResolve, and the middleware must 403 before any cookie is read. +// An unclassified route is not a real route, so no identity is required and the +// token validator must not be called. +func TestAccessControl_Deny_NoCookie_Returns403(t *testing.T) { + validator := &fakeValidator{err: errors.New("validate must not be called for a denied route")} + engine := buildEngine(validator, silentLogger(), http.MethodGet, "/api/unclassified", okHandler) + + req := httptest.NewRequest(http.MethodGet, "/api/unclassified", nil) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, "an unclassified route must be denied") + assert.Contains(t, rec.Body.String(), "FORBIDDEN") + assert.Equal(t, 0, validator.calls, "a denied route must not read the cookie or validate a token") +} + // --- Gateway-native classification: /health, /metrics, and unknown paths --- // TestGatewayResolve covers the gateway-owned classification that services/access // intentionally does not know about: the gateway-native /health and /metrics // endpoints are Public, while every other path is delegated to the shared // registry resolver (a real route keeps its level; an unknown path falls to the -// fail-safe Authenticated default). +// fail-safe Deny default). func TestGatewayResolve(t *testing.T) { cases := []struct { name string @@ -394,7 +412,7 @@ func TestGatewayResolve(t *testing.T) { {"metrics is public", http.MethodGet, "/metrics", sharedaccess.Public}, {"a real personal route keeps its level", http.MethodGet, "/api/finance/periods", sharedaccess.Personal}, {"a real admin route keeps its level", http.MethodGet, "/api/admin/users", sharedaccess.Admin}, - {"an unknown path falls to authenticated", http.MethodGet, "/api/unknown", sharedaccess.Authenticated}, + {"an unknown path falls to deny", http.MethodGet, "/api/unknown", sharedaccess.Deny}, } for _, tc := range cases { diff --git a/services/gateway/internal/router/router_test.go b/services/gateway/internal/router/router_test.go index 5d8d6864..f4441bb0 100644 --- a/services/gateway/internal/router/router_test.go +++ b/services/gateway/internal/router/router_test.go @@ -151,8 +151,8 @@ func TestRouter_ExpenseRoutes_RouteToExpenseService(t *testing.T) { method string path string }{ - {http.MethodPost, "/api/expenses/"}, - {http.MethodGet, "/api/expenses/?year=2026&month=5"}, + {http.MethodPost, "/api/expenses"}, + {http.MethodGet, "/api/expenses?year=2026&month=5"}, {http.MethodGet, "/api/expenses/suggestions?page=1&pageSize=50"}, {http.MethodGet, "/api/expenses/abc-123"}, {http.MethodPost, "/api/expenses/abc-123/correct"}, @@ -175,10 +175,10 @@ func TestRouter_FinanceRoutes_RouteToFinanceService(t *testing.T) { path string }{ {http.MethodGet, "/api/finance/periods/current?year=2026&month=5"}, - {http.MethodPost, "/api/finance/periods/"}, - {http.MethodGet, "/api/finance/tags/"}, - {http.MethodPost, "/api/finance/prorata/"}, - {http.MethodGet, "/api/finance/summary/?year=2026&month=5"}, + {http.MethodPost, "/api/finance/periods"}, + {http.MethodGet, "/api/finance/tags"}, + {http.MethodPost, "/api/finance/prorata"}, + {http.MethodGet, "/api/finance/summary?year=2026&month=5"}, } for _, tt := range tests { @@ -294,10 +294,14 @@ func TestRouter_UnauthenticatedRoutes_NoCookieNeeded(t *testing.T) { } } +// TestRouter_AuthenticatedRoute_NoCookie_Returns401 targets a real Authenticated +// route: with no cookie the gateway must 401 (identity required) before any +// proxying. (A non-real path like "/api/expenses/" is now Deny -> 403, so it +// would no longer exercise the missing-cookie 401 path.) func TestRouter_AuthenticatedRoute_NoCookie_Returns401(t *testing.T) { doRequest := setupGateway(t, userValidator()) - resp, _ := doRequest(http.MethodGet, "/api/expenses/", nil) + resp, _ := doRequest(http.MethodGet, "/api/auth/me", nil) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } From 2f2da671635f18444914e843024d87d6100972c6 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 03:42:12 +0100 Subject: [PATCH 67/70] refactor(gateway): derive proxy wiring from the shared prefix inventory 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. --- services/access/proxy.go | 32 +++++++++ services/access/proxy_test.go | 62 ++++++++++++++++++ services/gateway/internal/router/router.go | 65 +++++++------------ .../gateway/internal/router/router_test.go | 30 +++++++++ 4 files changed, 149 insertions(+), 40 deletions(-) create mode 100644 services/access/proxy.go create mode 100644 services/access/proxy_test.go diff --git a/services/access/proxy.go b/services/access/proxy.go new file mode 100644 index 00000000..e4aa5daf --- /dev/null +++ b/services/access/proxy.go @@ -0,0 +1,32 @@ +package access + +// ProxyPrefix ties a gateway URL prefix to the downstream service that serves +// every route under it. It is the routing half of the single source of truth: +// the Registry says how each route is classified, ProxyPrefixes says which +// service the gateway proxies each prefix to. +type ProxyPrefix struct { + // Prefix is the gin group prefix the gateway proxies (e.g. "/api/finance"). + Prefix string + // Service is the downstream service that serves the prefix, matching + // Route.Service in the Registry ("auth" | "finance" | "expense" | "datarights"). + Service string +} + +// ProxyPrefixes is the ordered inventory of downstream prefixes the gateway +// proxies and the service each targets. It makes "what prefixes and services +// exist" part of the single source of truth: the gateway derives its proxy +// wiring from this list (see services/gateway/internal/router), and a +// cross-check test pins it against the Registry so every classified route sits +// under a proxied prefix and every proxied prefix has at least one classified +// route. +// +// Two prefixes map to the auth service: /api/auth (its own routes) and +// /api/admin (operator routes such as GET /api/admin/users, still served by +// auth even though their Access is Admin). +var ProxyPrefixes = []ProxyPrefix{ + {Prefix: "/api/auth", Service: "auth"}, + {Prefix: "/api/admin", Service: "auth"}, + {Prefix: "/api/expenses", Service: "expense"}, + {Prefix: "/api/finance", Service: "finance"}, + {Prefix: "/api/datarights", Service: "datarights"}, +} diff --git a/services/access/proxy_test.go b/services/access/proxy_test.go new file mode 100644 index 00000000..d70715e3 --- /dev/null +++ b/services/access/proxy_test.go @@ -0,0 +1,62 @@ +package access + +import ( + "strings" + "testing" +) + +// underPrefix reports whether a concrete route path sits under a proxy prefix +// on a segment boundary: an exact match, or the prefix followed by "/". This +// mirrors how the gateway groups proxy a prefix and its subtree, and rejects +// leading-substring siblings like "/api/expenses-report" under "/api/expenses". +func underPrefix(path, prefix string) bool { + return path == prefix || strings.HasPrefix(path, prefix+"/") +} + +// coveringPrefix returns the single ProxyPrefix a path sits under (prefixes are +// disjoint on segment boundaries, so at most one matches). +func coveringPrefix(path string) (ProxyPrefix, bool) { + for _, p := range ProxyPrefixes { + if underPrefix(path, p.Prefix) { + return p, true + } + } + return ProxyPrefix{}, false +} + +// TestProxyPrefixes_EveryRegistryRouteIsReachable is direction one of the +// single-source cross-check: every classified route must sit under a proxied +// prefix (no classified-but-unreachable route), and that prefix must proxy the +// same service that serves the route (else the gateway would forward it to the +// wrong downstream). +func TestProxyPrefixes_EveryRegistryRouteIsReachable(t *testing.T) { + for _, r := range Registry { + prefix, ok := coveringPrefix(r.Path) + if !ok { + t.Errorf("route %s (%s %s) is classified but sits under no ProxyPrefix; the gateway would never proxy it (add a ProxyPrefix for its prefix)", r.ID, r.Method, r.Path) + continue + } + if prefix.Service != r.Service { + t.Errorf("route %s (%s) is served by %q but its prefix %q proxies to %q; the gateway would route it to the wrong service", r.ID, r.Path, r.Service, prefix.Prefix, prefix.Service) + } + } +} + +// TestProxyPrefixes_EveryPrefixHasARoute is direction two of the cross-check: +// every proxied prefix must have at least one classified route under it. A +// prefix with no Registry route is proxied-but-unclassified, so under +// deny-by-default every request to it would 403 (a silently dead prefix). +func TestProxyPrefixes_EveryPrefixHasARoute(t *testing.T) { + for _, p := range ProxyPrefixes { + found := false + for _, r := range Registry { + if underPrefix(r.Path, p.Prefix) { + found = true + break + } + } + if !found { + t.Errorf("ProxyPrefix %q (-> %q) has no Registry route under it; it proxies an unclassified prefix where every request would be denied (add a Registry entry or remove the prefix)", p.Prefix, p.Service) + } + } +} diff --git a/services/gateway/internal/router/router.go b/services/gateway/internal/router/router.go index 4297995d..e036d0f5 100644 --- a/services/gateway/internal/router/router.go +++ b/services/gateway/internal/router/router.go @@ -1,12 +1,14 @@ package router import ( + "fmt" "log/slog" "net/http" "net/url" "github.com/gin-gonic/gin" + sharedaccess "github.com/ItsThompson/gofin/services/access" "github.com/ItsThompson/gofin/services/gateway/internal/access" "github.com/ItsThompson/gofin/services/gateway/internal/middleware" "github.com/ItsThompson/gofin/services/gateway/internal/proxy" @@ -53,48 +55,31 @@ func New( c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - // Build reverse proxy handlers for each downstream service. - authProxy := proxy.NewServiceProxy(serviceURLs.AuthREST, logger) - expenseProxy := proxy.NewServiceProxy(serviceURLs.ExpenseREST, logger) - financeProxy := proxy.NewServiceProxy(serviceURLs.FinanceREST, logger) - datarightsProxy := proxy.NewServiceProxy(serviceURLs.DatarightsREST, logger) - - // Route groups are pure proxy wiring: access is enforced globally by - // AccessControl against the policy table, not per group. - - // /api/auth/* → Auth service (REST) - authGroup := engine.Group("/api/auth") - { - authGroup.Any("", ginWrapHandler(authProxy)) - authGroup.Any("/*path", ginWrapHandler(authProxy)) - } - - // /api/admin/* → Auth service (REST) - adminGroup := engine.Group("/api/admin") - { - adminGroup.Any("", ginWrapHandler(authProxy)) - adminGroup.Any("/*path", ginWrapHandler(authProxy)) - } - - // /api/expenses/* → Expense service (REST) - expenseGroup := engine.Group("/api/expenses") - { - expenseGroup.Any("", ginWrapHandler(expenseProxy)) - expenseGroup.Any("/*path", ginWrapHandler(expenseProxy)) - } - - // /api/finance/* → Finance service (REST) - financeGroup := engine.Group("/api/finance") - { - financeGroup.Any("", ginWrapHandler(financeProxy)) - financeGroup.Any("/*path", ginWrapHandler(financeProxy)) + // Build one reverse-proxy handler per downstream service, keyed by the + // service name used in the shared Registry and ProxyPrefixes. + proxies := map[string]http.Handler{ + "auth": proxy.NewServiceProxy(serviceURLs.AuthREST, logger), + "expense": proxy.NewServiceProxy(serviceURLs.ExpenseREST, logger), + "finance": proxy.NewServiceProxy(serviceURLs.FinanceREST, logger), + "datarights": proxy.NewServiceProxy(serviceURLs.DatarightsREST, logger), } - // /api/datarights/* → Datarights service (REST) - datarightsGroup := engine.Group("/api/datarights") - { - datarightsGroup.Any("", ginWrapHandler(datarightsProxy)) - datarightsGroup.Any("/*path", ginWrapHandler(datarightsProxy)) + // Derive the proxy wiring from the shared prefix inventory so onboarding a + // service is a single edit to services/access.ProxyPrefixes (which the + // cross-check test pins to the Registry). Access is enforced globally by + // AccessControl against the Registry, not per group. Fail fast if a prefix + // names a service with no proxy handler. + for _, p := range sharedaccess.ProxyPrefixes { + handler, ok := proxies[p.Service] + if !ok { + panic(fmt.Sprintf( + "ProxyPrefix %q names service %q, which has no proxy handler; add its ServiceURL and proxy to router.New", + p.Prefix, p.Service, + )) + } + group := engine.Group(p.Prefix) + group.Any("", ginWrapHandler(handler)) + group.Any("/*path", ginWrapHandler(handler)) } return engine diff --git a/services/gateway/internal/router/router_test.go b/services/gateway/internal/router/router_test.go index f4441bb0..17d9eb24 100644 --- a/services/gateway/internal/router/router_test.go +++ b/services/gateway/internal/router/router_test.go @@ -2,6 +2,7 @@ package router_test import ( "context" + "fmt" "io" "log/slog" "net/http" @@ -10,7 +11,9 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sharedaccess "github.com/ItsThompson/gofin/services/access" "github.com/ItsThompson/gofin/services/gateway/internal/access" "github.com/ItsThompson/gofin/services/gateway/internal/router" ) @@ -312,3 +315,30 @@ func TestRouter_HealthEndpoint(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, body, `"status":"ok"`) } + +// TestRouter_New_PanicsOnPrefixWithNoProxy pins the data-driven wiring's +// fail-fast contract: because router.New derives its proxy groups from +// sharedaccess.ProxyPrefixes, a prefix naming a service with no proxy handler +// is a wiring bug that must panic at construction rather than silently drop the +// prefix. The bad prefix is appended and restored so other tests are unaffected. +func TestRouter_New_PanicsOnPrefixWithNoProxy(t *testing.T) { + original := sharedaccess.ProxyPrefixes + sharedaccess.ProxyPrefixes = append(append([]sharedaccess.ProxyPrefix{}, original...), + sharedaccess.ProxyPrefix{Prefix: "/api/ghost", Service: "ghost"}) + t.Cleanup(func() { sharedaccess.ProxyPrefixes = original }) + + defer func() { + r := recover() + require.NotNil(t, r, "New must panic when a ProxyPrefix names a service with no proxy handler") + assert.Contains(t, fmt.Sprintf("%v", r), "ghost", + "panic message must name the offending service") + }() + + u, _ := url.Parse("http://127.0.0.1:1") + router.New(userValidator(), &router.ServiceURLs{ + AuthREST: u, + ExpenseREST: u, + FinanceREST: u, + DatarightsREST: u, + }, newSilentLogger(), false) +} From fea2233166623e3196fe2976c3cf95bbaa6e90de Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 03:42:19 +0100 Subject: [PATCH 68/70] docs: describe deny-by-default and the proxy-prefix single source 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 --- docs/api.md | 10 ++++++---- docs/architecture.md | 4 +++- docs/auth.md | 2 +- docs/testing.md | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/api.md b/docs/api.md index cae2d8ef..fcf676fd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,7 +15,7 @@ Canonical sources for endpoint definitions: ## Gateway Routing -The gateway applies one centralized access-control policy: every route resolves to exactly one of four access levels, enforced by the single `AccessControl` middleware. Resolution is exact match first, then the longest matching path prefix, else the fail-safe default of `Authenticated`. +The gateway applies one centralized access-control policy: every route resolves to exactly one of four access levels, enforced by the single `AccessControl` middleware. Resolution matches the concrete route gin will dispatch to; a path that no registry entry classifies falls to the deny-by-default fail-safe and is refused with **403** (an unclassified `/api/*` path is not a real route). | Level | Meaning | Token required | Role check | |-------|---------|----------------|------------| @@ -26,6 +26,8 @@ The gateway applies one centralized access-control policy: every route resolves ### Route Groups +This prefix→service mapping is the single source of truth in `services/access.ProxyPrefixes`, from which the gateway derives its reverse-proxy wiring (a cross-check test pins it against the route registry). + | URL Prefix | Downstream Service | Access Level | |------------|-------------------|--------------| | `/api/auth/*` | Auth Service | Mixed (see route-level table): Public login/register/refresh, Personal onboarding-complete, Admin assume, Authenticated for the rest | @@ -55,7 +57,7 @@ The canonical route registry (`services/access/registry.go`) classifies each rou | `Authenticated` | (any) | `/api/auth/me`, `/api/auth/me/password` | | `Authenticated` | POST | `/api/auth/logout` | | `Authenticated` | POST | `/api/auth/restore` | -| `Authenticated` (default) | (any) | *(any path with no registry entry, e.g. bare `/api/auth`)* | +| `Deny` (default) | (any) | *(any `/api/*` path with no registry entry, e.g. bare `/api/auth`); refused with **403** before any token read* | The `Personal` routes are `/api/finance/*`, `/api/expenses/*`, `/api/datarights/exports*`, and `POST /api/auth/onboarding-complete`. A direct admin (`role=admin`) receives **403** on all of them; an assumed session carries `role=user` (with an `assumedBy` claim) and passes. `POST /api/auth/restore` is `Authenticated`, so an assumed session can always restore. @@ -64,8 +66,8 @@ The `Personal` routes are `/api/finance/*`, `/api/expenses/*`, `/api/datarights/ A single `AccessControl` middleware gates every request. For each one it: 1. Strips client-supplied identity headers (`X-User-ID`, `X-User-Role`, `X-Assumed-By`) so they can never be spoofed -2. Resolves the route's access level from the policy table: exact match first, then the longest matching prefix, else the default of `Authenticated` -3. `Public` routes short-circuit here with no token read +2. Resolves the route's access level from the registry, matching the concrete route gin dispatches; a path with no registry entry falls to the deny-by-default fail-safe (**403**) +3. `Public` routes short-circuit here with no token read; a `Deny` (unclassified) path short-circuits with a **403**, also with no token read 4. Otherwise extracts the `gofin_access` cookie and calls Auth Service gRPC `ValidateToken` (401 on a missing cookie or validation failure; the frontend then handles refresh) 5. Sets `X-User-ID` and `X-User-Role` (and `X-Assumed-By` when the session is assumed) for the downstream service 6. Enforces the level's role: `Authenticated` passes any valid token; `Personal` requires `role == "user"`; `Admin` requires `role == "admin"`. A role mismatch returns 403 diff --git a/docs/architecture.md b/docs/architecture.md index 07d39f6b..6372fdee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,13 +85,15 @@ The shell owns: A lightweight Go/Gin reverse proxy that validates auth and routes requests. A single centralized `AccessControl` middleware backed by the shared `services/access` route registry classifies every route into one of four access levels (Public / Authenticated / Personal / Admin) and enforces it: 1. Strips client-supplied identity headers so they cannot be spoofed -2. Resolves the route's access level from the shared registry, matching the concrete route gin will dispatch to (else the fail-safe default of `Authenticated`) +2. Resolves the route's access level from the shared registry, matching the concrete route gin will dispatch to (else the deny-by-default fail-safe: an unclassified path is refused with **403**, so a route or whole prefix is dead until it is added to the registry with an access level) 3. `Public` routes pass with no token read (e.g. `/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/health`, `/metrics`) 4. Otherwise verifies the `gofin_access` cookie via Auth Service gRPC `ValidateToken` (401 on failure) and injects `X-User-ID`, `X-User-Role`, and (when assuming) `X-Assumed-By` 5. Enforces the level's role: `Personal` routes require `role == "user"` and `Admin` routes require `role == "admin"` (403 on mismatch) This one middleware replaced the former `unauthenticatedRoutes` allowlist, `RequireAdmin`, and `AdminRouteGuard`. Because the personal finance routes are `Personal`, a direct admin is refused there while an assumed regular-user session passes. +The set of proxied prefixes and their downstream services is itself a single source of truth (`services/access.ProxyPrefixes`), from which the gateway derives its proxy wiring; a cross-check test pins it to the registry so every classified route sits under a proxied prefix and every proxied prefix has at least one classified route. + ### Auth Service (Node 2) Owns user identity, credentials, and token lifecycle: diff --git a/docs/auth.md b/docs/auth.md index 4ae2f916..29065280 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -52,7 +52,7 @@ exp : Expiration timestamp ### Gateway Access Levels -The gateway classifies every route into one of four access levels via a single ordered policy table (`services/gateway/internal/access`). A route is resolved by exact match first, then the longest matching prefix, else the fail-safe default of `Authenticated`: +The gateway classifies every route into one of four access levels via the shared `services/access` route registry. A route is resolved to the concrete route gin dispatches; a path that no registry entry classifies falls to the deny-by-default fail-safe and is refused with **403**: | Level | Meaning | Token required | Role check | |-------|---------|----------------|------------| diff --git a/docs/testing.md b/docs/testing.md index 7488d84d..3dda1cd0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -104,8 +104,8 @@ func TestExpenseRepository_Integration(t *testing.T) { | Finance: pro-rata scheduling | High | Installment math, remainder handling, year rollover, application at period creation | | Auth: token lifecycle | High | Generation, validation, refresh rotation, blacklisting, `tokens_revoked_at` | | Auth: RBAC | High | Role checking, identity assumption, audit claims | -| Access registry + resolver (`services/access`) | High | Every `Registry` route resolves to its declared level; unique IDs; gin-priority matching (static > param > wildcard) on real overlaps; unknown/wrong-method paths fall to the `Authenticated` default | -| Gateway: `AccessControl` middleware | High | Per-level/per-role outcomes (pass/401/403), direct admin vs assumed `role=user` on Personal routes, header stripping, `X-Assumed-By` forwarding | +| Access registry + resolver (`services/access`) | High | Every `Registry` route resolves to its declared level; unique IDs; gin-priority matching (static > param > wildcard) on real overlaps; unknown/wrong-method paths fall to the deny-by-default fail-safe (403); `ProxyPrefixes` cross-checks the Registry (every classified route sits under a proxied prefix and every proxied prefix is classified) | +| Gateway: `AccessControl` middleware | High | Per-level/per-role outcomes (pass/401/403), a `Deny` (unclassified) path 403s with no token read, direct admin vs assumed `role=user` on Personal routes, header stripping, `X-Assumed-By` forwarding | | Finance: aggregations | Medium | Category sums, tag spending, cumulative spend | | Auth: password handling | Medium | Hashing, verification, strength validation | | Service route coverage (registry-driven) | Medium | Each service registers routes from the `services/access` Registry; a per-service test asserts `engine.Routes()` matches the Registry both ways, so adding an unclassified route fails that service's own `go test` in CI | From 2372668df08850d43ac8fdf5427db749b0eead25 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 04:12:18 +0100 Subject: [PATCH 69/70] refactor(shell): type route handles + drop dead guard - 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 --- .../shell-layout/hooks/useAuthLayoutGuards.ts | 6 ++++- frontend/apps/shell/app/lib/route-access.ts | 10 ++++++++ .../apps/shell/app/routes/admin-users.tsx | 3 ++- frontend/apps/shell/app/routes/admin.tsx | 25 +++++++++---------- frontend/apps/shell/app/routes/dashboard.tsx | 3 ++- .../apps/shell/app/routes/expenses-new.tsx | 3 ++- frontend/apps/shell/app/routes/expenses.tsx | 3 ++- frontend/apps/shell/app/routes/history.tsx | 3 ++- frontend/apps/shell/app/routes/home.tsx | 3 ++- frontend/apps/shell/app/routes/onboarding.tsx | 3 ++- frontend/apps/shell/app/routes/settings.tsx | 3 ++- 11 files changed, 43 insertions(+), 22 deletions(-) diff --git a/frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts b/frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts index 60d57be9..d1c694f2 100644 --- a/frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts +++ b/frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts @@ -46,8 +46,12 @@ export function useAuthLayoutGuards({ isLoading, }: AuthLayoutState): AuthLayoutGuard { const matches = useMatches(); - const { pathname } = useLocation(); const access = deepestAccess(matches); + // Access comes from route metadata (the deepest matched handle.access); the + // current path comes from useLocation. These are intentionally distinct + // sources: one is the route's declared classification, the other is the live + // URL, read only to tell whether we are on the onboarding route. + const { pathname } = useLocation(); if (isLoading) { return { status: "loading" }; diff --git a/frontend/apps/shell/app/lib/route-access.ts b/frontend/apps/shell/app/lib/route-access.ts index c8fa07f4..2c2ef927 100644 --- a/frontend/apps/shell/app/lib/route-access.ts +++ b/frontend/apps/shell/app/lib/route-access.ts @@ -13,6 +13,16 @@ import { canUseAdminFeatures, canUseFinanceFeatures, type User } from "@gofin/co */ export type RouteAccess = "public" | "authenticated" | "personal" | "admin"; +/** + * Builds a route `handle` carrying its access level. Route modules use this + * instead of an inline object literal so the access value is type-checked + * against RouteAccess at the call site: a typo (e.g. "personl") fails to + * compile rather than silently resolving to a forbidden route at runtime. + */ +export function accessHandle(access: RouteAccess): { access: RouteAccess } { + return { access }; +} + /** * canAccess is the single predicate the auth-layout guard applies to a route's * `handle.access`. It generalizes the old FINANCE_ROUTES check into one rule diff --git a/frontend/apps/shell/app/routes/admin-users.tsx b/frontend/apps/shell/app/routes/admin-users.tsx index 5929b39f..10cff9cb 100644 --- a/frontend/apps/shell/app/routes/admin-users.tsx +++ b/frontend/apps/shell/app/routes/admin-users.tsx @@ -1,10 +1,11 @@ import { Navigate } from "react-router"; +import { accessHandle } from "@/lib/route-access"; /** * /admin/users redirects to /admin which contains the full admin panel. * The admin panel page handles user list display. */ -export const handle = { access: "admin" as const }; +export const handle = accessHandle("admin"); export default function AdminUsersPage() { return <Navigate to="/admin" replace />; diff --git a/frontend/apps/shell/app/routes/admin.tsx b/frontend/apps/shell/app/routes/admin.tsx index 702f5aec..f03afca6 100644 --- a/frontend/apps/shell/app/routes/admin.tsx +++ b/frontend/apps/shell/app/routes/admin.tsx @@ -1,14 +1,16 @@ import { useNavigate } from "react-router"; import { useAuthStore } from "@/stores/auth-store"; -import { useEffect, lazy } from "react"; +import { lazy } from "react"; import { RemoteBoundary } from "@/components/remote-boundary"; import { Skeleton } from "@gofin/ui/components/skeleton"; import { Card, CardContent, CardHeader } from "@gofin/ui/components/card"; +import { accessHandle } from "@/lib/route-access"; /** - * Lazy-load the AdminPanelPage from the admin remote package. - * This creates a code-split chunk: non-admin users never download this code - * because the admin route guard redirects them before rendering. + * Lazy-load the AdminPanelPage from the admin remote package. This creates a + * code-split chunk: the auth-layout guard renders a 403 for any non-admin + * identity (see route-access `canAccess`), so this page only ever mounts for a + * direct admin and non-admins never download the remote chunk. */ const AdminPanelPage = lazy(() => import("@gofin/admin/src/pages/AdminPanelPage").then((mod) => ({ @@ -58,19 +60,16 @@ function AdminSkeleton() { ); } -export const handle = { access: "admin" as const }; +export const handle = accessHandle("admin"); export default function AdminPage() { - const { user, isAdmin, isLoading, assumeIdentity } = useAuthStore(); + const { user, assumeIdentity } = useAuthStore(); const navigate = useNavigate(); - useEffect(() => { - if (!isLoading && !isAdmin) { - navigate("/dashboard"); - } - }, [isLoading, isAdmin, navigate]); - - if (isLoading || !isAdmin) { + // The auth-layout guard renders <Forbidden/> for any non-direct-admin before + // this route mounts, so no in-component role guard is needed; user is + // guaranteed present in the layout's "ready" state. + if (!user) { return null; } diff --git a/frontend/apps/shell/app/routes/dashboard.tsx b/frontend/apps/shell/app/routes/dashboard.tsx index 7be0318d..c71121ae 100644 --- a/frontend/apps/shell/app/routes/dashboard.tsx +++ b/frontend/apps/shell/app/routes/dashboard.tsx @@ -2,6 +2,7 @@ import { lazy } from "react"; import { useAuthStore } from "@/stores/auth-store"; import { RemoteBoundary } from "@/components/remote-boundary"; import { DashboardSkeleton } from "@gofin/ui/components/skeletons"; +import { accessHandle } from "@/lib/route-access"; /** * Lazy-load the DashboardFeature from the finance remote package. @@ -14,7 +15,7 @@ const DashboardFeature = lazy(() => })), ); -export const handle = { access: "personal" as const }; +export const handle = accessHandle("personal"); export default function DashboardRoute() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/expenses-new.tsx b/frontend/apps/shell/app/routes/expenses-new.tsx index 5810e0ed..d61a1bd8 100644 --- a/frontend/apps/shell/app/routes/expenses-new.tsx +++ b/frontend/apps/shell/app/routes/expenses-new.tsx @@ -3,6 +3,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { RemoteBoundary } from "@/components/remote-boundary"; import { Skeleton } from "@gofin/ui/components/skeleton"; import { Card, CardContent, CardHeader } from "@gofin/ui/components/card"; +import { accessHandle } from "@/lib/route-access"; /** * Lazy-load the NewExpenseFeature from the finance remote package. @@ -38,7 +39,7 @@ function ExpenseFormSkeleton() { ); } -export const handle = { access: "personal" as const }; +export const handle = accessHandle("personal"); export default function NewExpenseRoute() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/expenses.tsx b/frontend/apps/shell/app/routes/expenses.tsx index 4c88e549..31e4502b 100644 --- a/frontend/apps/shell/app/routes/expenses.tsx +++ b/frontend/apps/shell/app/routes/expenses.tsx @@ -2,6 +2,7 @@ import { lazy } from "react"; import { useAuthStore } from "@/stores/auth-store"; import { RemoteBoundary } from "@/components/remote-boundary"; import { ExpenseLogSkeleton } from "@gofin/ui/components/skeletons"; +import { accessHandle } from "@/lib/route-access"; /** * Lazy-load the ExpenseLogFeature from the finance remote package. @@ -12,7 +13,7 @@ const ExpenseLogFeature = lazy(() => })), ); -export const handle = { access: "personal" as const }; +export const handle = accessHandle("personal"); export default function ExpensesRoute() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/history.tsx b/frontend/apps/shell/app/routes/history.tsx index 78e7c9b8..bae01732 100644 --- a/frontend/apps/shell/app/routes/history.tsx +++ b/frontend/apps/shell/app/routes/history.tsx @@ -2,6 +2,7 @@ import { lazy } from "react"; import { useAuthStore } from "@/stores/auth-store"; import { RemoteBoundary } from "@/components/remote-boundary"; import { Skeleton } from "@gofin/ui/components/skeleton"; +import { accessHandle } from "@/lib/route-access"; /** * Lazy-load the HistoryFeature from the finance remote package. @@ -28,7 +29,7 @@ function HistoryLoadingSkeleton() { ); } -export const handle = { access: "personal" as const }; +export const handle = accessHandle("personal"); export default function HistoryRoute() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/home.tsx b/frontend/apps/shell/app/routes/home.tsx index 57373c27..f5653d7d 100644 --- a/frontend/apps/shell/app/routes/home.tsx +++ b/frontend/apps/shell/app/routes/home.tsx @@ -1,9 +1,10 @@ import { Navigate } from "react-router"; import { getLandingPath } from "@gofin/core"; import { useAuthStore } from "@/stores/auth-store"; +import { accessHandle } from "@/lib/route-access"; /** Root index route: send each identity to its role-aware landing path. */ -export const handle = { access: "authenticated" as const }; +export const handle = accessHandle("authenticated"); export default function HomePage() { const { user } = useAuthStore(); diff --git a/frontend/apps/shell/app/routes/onboarding.tsx b/frontend/apps/shell/app/routes/onboarding.tsx index bc44ad32..0389c6b0 100644 --- a/frontend/apps/shell/app/routes/onboarding.tsx +++ b/frontend/apps/shell/app/routes/onboarding.tsx @@ -1,6 +1,7 @@ import { OnboardingFeature } from "@/features/onboarding"; +import { accessHandle } from "@/lib/route-access"; -export const handle = { access: "personal" as const }; +export const handle = accessHandle("personal"); export default function OnboardingRoute() { return <OnboardingFeature />; diff --git a/frontend/apps/shell/app/routes/settings.tsx b/frontend/apps/shell/app/routes/settings.tsx index c1b46e2f..e02f8909 100644 --- a/frontend/apps/shell/app/routes/settings.tsx +++ b/frontend/apps/shell/app/routes/settings.tsx @@ -2,6 +2,7 @@ import { lazy, useCallback } from "react"; import { useAuthStore } from "@/stores/auth-store"; import { RemoteBoundary } from "@/components/remote-boundary"; import { SettingsSkeleton } from "@gofin/ui/components/skeletons"; +import { accessHandle } from "@/lib/route-access"; /** * Lazy-load the SettingsFeature from the finance remote package. @@ -12,7 +13,7 @@ const SettingsPage = lazy(() => })), ); -export const handle = { access: "authenticated" as const }; +export const handle = accessHandle("authenticated"); export default function SettingsRoute() { const { user, checkAuth } = useAuthStore(); From dd144b8ef3a2eddd4dfd4e7bfb410f5931aa8ff1 Mon Sep 17 00:00:00 2001 From: thompson <hi.thompson@hotmail.com> Date: Sun, 5 Jul 2026 04:12:29 +0100 Subject: [PATCH 70/70] test(shell): cover canAccess and navLinksFor - 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 --- .../shell/app/__tests__/route-access.test.ts | 55 +++++++++++++++++++ .../shell-layout/__tests__/nav-links.test.ts | 35 ++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 frontend/apps/shell/app/__tests__/route-access.test.ts create mode 100644 frontend/apps/shell/app/features/shell-layout/__tests__/nav-links.test.ts diff --git a/frontend/apps/shell/app/__tests__/route-access.test.ts b/frontend/apps/shell/app/__tests__/route-access.test.ts new file mode 100644 index 00000000..1f15cbb4 --- /dev/null +++ b/frontend/apps/shell/app/__tests__/route-access.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { buildUser } from "@gofin/test-utils"; +import { canAccess } from "@/lib/route-access"; + +// canAccess is the single guard predicate the auth layout applies to a route's +// handle.access. This truth table pins its contract across +// {public, authenticated, personal, admin} x {direct admin, regular user, +// assumed session}. An assumed session carries role=user with isAssuming=true. +const regularUser = buildUser({ role: "user" }); +const adminUser = buildUser({ role: "admin" }); + +describe("canAccess", () => { + describe("public and authenticated are role-agnostic", () => { + it.each(["public", "authenticated"] as const)( + "%s is reachable by a regular user, a direct admin, and an assumed session", + (access) => { + expect(canAccess(regularUser, false, access)).toBe(true); + expect(canAccess(adminUser, false, access)).toBe(true); + expect(canAccess(regularUser, true, access)).toBe(true); + }, + ); + }); + + describe("personal requires a finance-capable (role=user) identity", () => { + it("allows a regular user", () => { + expect(canAccess(regularUser, false, "personal")).toBe(true); + }); + + it("allows an assumed session (role=user, isAssuming=true)", () => { + expect(canAccess(regularUser, true, "personal")).toBe(true); + }); + + it("denies a direct admin", () => { + expect(canAccess(adminUser, false, "personal")).toBe(false); + }); + }); + + describe("admin requires a direct operator (role=admin, not assuming)", () => { + it("allows a direct admin", () => { + expect(canAccess(adminUser, false, "admin")).toBe(true); + }); + + it("denies a regular user", () => { + expect(canAccess(regularUser, false, "admin")).toBe(false); + }); + + it("denies an admin while assuming a user", () => { + expect(canAccess(adminUser, true, "admin")).toBe(false); + }); + + it("denies an assumed session (role=user)", () => { + expect(canAccess(regularUser, true, "admin")).toBe(false); + }); + }); +}); diff --git a/frontend/apps/shell/app/features/shell-layout/__tests__/nav-links.test.ts b/frontend/apps/shell/app/features/shell-layout/__tests__/nav-links.test.ts new file mode 100644 index 00000000..bf2ebb4e --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/__tests__/nav-links.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { navLinksFor } from "../nav-links"; + +// navLinksFor is the pure helper the navbar uses to pick its link set. A direct +// admin (operator, not assuming) gets the operator surface; a regular user or +// an assumed session (both role=user) gets the finance surface. +describe("navLinksFor", () => { + it("returns the operator surface for a direct admin", () => { + const links = navLinksFor(true); + expect(links.map((link) => link.to)).toEqual(["/admin", "/settings"]); + expect(links.map((link) => link.label)).toEqual(["Admin", "Settings"]); + }); + + it("returns the finance surface for a regular user or assumed session", () => { + const links = navLinksFor(false); + expect(links.map((link) => link.to)).toEqual([ + "/dashboard", + "/expenses", + "/history", + "/settings", + ]); + expect(links.map((link) => link.label)).toEqual([ + "Dashboard", + "Expenses", + "History", + "Settings", + ]); + }); + + it("gives every link an icon", () => { + for (const link of [...navLinksFor(true), ...navLinksFor(false)]) { + expect(link.icon).toBeTruthy(); + } + }); +});