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 diff --git a/.pi/skills/change-management/SKILL.md b/.pi/skills/change-management/SKILL.md new file mode 100644 index 00000000..68341a25 --- /dev/null +++ b/.pi/skills/change-management/SKILL.md @@ -0,0 +1,245 @@ +--- +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) +└── _[_]/ # 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` = zero-padded sequential id, `` = lowercase kebab-case. Regex: `^\d{3}_[a-z0-9]+(-[a-z0-9]+)*$` | +| Item folder (completed) | `__` 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/_ + ``` + +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_ + 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 _ + +# non-default root (rarely needed) +python change-management/.validation/validate_change_management.py --root change-management +``` + +- Exit `0`: all validated items conform. +- 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 +`.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/_ + +# overwrite an existing log +python change-management/.tools/generate_execution_log.py change-management/_ --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 `). +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/_ change-management/__ + ``` + + where `` 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. | 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 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 diff --git a/change-management/.tools/.gitkeep b/change-management/.tools/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/change-management/.tools/generate_execution_log.py b/change-management/.tools/generate_execution_log.py new file mode 100755 index 00000000..f6ae35e3 --- /dev/null +++ b/change-management/.tools/generate_execution_log.py @@ -0,0 +1,246 @@ +#!/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 [--force] + writes /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.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}_", + f"where 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 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 + + print(f"wrote {output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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..64bb55bb --- /dev/null +++ b/change-management/.tools/test_generate_execution_log.py @@ -0,0 +1,251 @@ +#!/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_", self.log) + 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: + 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) 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 diff --git a/change-management/.validation/.gitkeep b/change-management/.validation/.gitkeep new file mode 100644 index 00000000..e69de29b 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..c9f81025 --- /dev/null +++ b/change-management/.validation/test_validate_change_management.py @@ -0,0 +1,253 @@ +#!/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("") + 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_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:") \ + + _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) 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 ``) 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 '_' or " + f"'__' " + 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/. + 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()) diff --git a/change-management/000-template/assets/.gitkeep b/change-management/000-template/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/change-management/000-template/description.md b/change-management/000-template/description.md new file mode 100644 index 00000000..c9a9bf93 --- /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? + + diff --git a/change-management/000-template/preflight.md b/change-management/000-template/preflight.md new file mode 100644 index 00000000..36a946e4 --- /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..acb7e1b4 --- /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> 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); 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..516494ca --- /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.: + `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: +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. diff --git a/docs/api.md b/docs/api.md index 06718fc0..fcf676fd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,23 +15,64 @@ 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 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 | +|-------|---------|----------------|------------| +| `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 + +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 | +| `/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 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` | +| `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. ### 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 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 + +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 +183,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 | diff --git a/docs/architecture.md b/docs/architecture.md index 43da6eb9..6372fdee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,18 +78,21 @@ 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 the shared `services/access` route registry 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 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) -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. + +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) @@ -99,7 +102,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) diff --git a/docs/auth.md b/docs/auth.md index 6d70ea38..29065280 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 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 | +|-------|---------|----------------|------------| +| `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 diff --git a/docs/data-model.md b/docs/data-model.md index 5f1fc0e7..03eb1178 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. + ### `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. 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. diff --git a/docs/development.md b/docs/development.md index 2e4ad106..e98ab566 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 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. +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 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`; 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 a4282b35..3dda1cd0 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 | +| 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 | -| Gateway: routing | Medium | Route matching, header forwarding | +| 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 | 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; 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 | | 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 diff --git a/frontend/apps/finance/src/features/settings/SettingsFeature.tsx b/frontend/apps/finance/src/features/settings/SettingsFeature.tsx index e784fe73..86f0cce5 100644 --- a/frontend/apps/finance/src/features/settings/SettingsFeature.tsx +++ b/frontend/apps/finance/src/features/settings/SettingsFeature.tsx @@ -1,63 +1,28 @@ 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 }; + // 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"> @@ -69,7 +34,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 +57,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 +85,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/__tests__/SettingsFeature-admin.test.tsx b/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx new file mode 100644 index 00000000..5146152a --- /dev/null +++ b/frontend/apps/finance/src/features/settings/__tests__/SettingsFeature-admin.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +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; + +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(); + }); + + 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"); + }); +}); 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"); + }); +}); 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..afc41767 --- /dev/null +++ b/frontend/apps/finance/src/features/settings/settingsTabs.tsx @@ -0,0 +1,82 @@ +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"; +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. + * Reuses the canonical page-level props ({ user, onUserUpdated? }). + */ +export interface SettingsTabDefinition { + id: SettingsTabId; + label: string; + icon: typeof Wallet; + render: (props: SettingsPageProps) => 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; +} diff --git a/frontend/apps/shell/app/__tests__/auth-layout.test.tsx b/frontend/apps/shell/app/__tests__/auth-layout.test.tsx index b8c5b3c8..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,88 +139,183 @@ 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("shows admin nav link when user is 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, + json: () => Promise.resolve({ user: adminUser }), }); 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.getByText("Admin")).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("shows Return to Admin button when assuming identity", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - isAssuming: true, - user: authenticatedUser, + 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("renders a 403 for a direct admin on /onboarding (personal), never the onboarding outlet", async () => { + const unonboardedAdmin = { ...adminUser, hasCompletedOnboarding: false }; + resetStore({ isAuthenticated: true, isAdmin: true, user: unonboardedAdmin }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + 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: "/login", element: <div>Login redirect target</div> }, - { path: "/admin", element: <div>Admin page</div> }, + layoutRoute(AuthLayout, "/onboarding", "personal", "Onboarding content"), + ...destinationRoutes, + ], + "/onboarding", + ); + + expect(await screen.findByText("403: Access denied")).toBeInTheDocument(); + expect(screen.queryByText("Onboarding content")).not.toBeInTheDocument(); + }); + + 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: unonboardedAdmin }), + }); + const AuthLayout = await importAuthLayout(); + + renderRouter( + [ + layoutRoute(AuthLayout, "/admin", "admin", "Admin content"), + ...destinationRoutes, ], - { initialEntries: ["/dashboard"] }, + "/admin", ); - render(<RouterProvider router={router} />); + 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, + ], + "/dashboard", + ); + + expect(await screen.findByText("Dashboard content")).toBeInTheDocument(); expect(screen.getByText("Return to Admin")).toBeInTheDocument(); + expect(screen.queryByText("403: Access denied")).not.toBeInTheDocument(); }); - it("restores identity and navigates to /admin on Return to Admin click", async () => { - resetStore({ - isLoading: false, - isAuthenticated: true, - isAssuming: true, - user: authenticatedUser, + it("shows only Admin and Settings nav for a direct admin (no finance links, no FAB)", async () => { + resetStore({ isAuthenticated: true, isAdmin: true, user: adminUser }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ user: adminUser }), }); const AuthLayout = await importAuthLayout(); - // checkAuth returns assumed user, then restoreIdentity returns admin + renderRouter( + [ + layoutRoute(AuthLayout, "/settings", "authenticated", "Settings content"), + ...destinationRoutes, + ], + "/settings", + ); + + 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(); + + expect(screen.getByText("GoFin").closest("a")).toHaveAttribute( + "href", + "/admin", + ); + }); + + it("keeps full user nav plus Return to Admin for an assumed session", async () => { + resetStore({ isAuthenticated: true, isAssuming: true, user: authenticatedUser }); + const AuthLayout = await importAuthLayout(); + + renderRouter( + [ + layoutRoute(AuthLayout, "/dashboard", "personal", "Dashboard content"), + ...destinationRoutes, + ], + "/dashboard", + ); + + 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(); + expect(screen.queryByText("Admin")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Log Expense")).not.toBeInTheDocument(); + expect(screen.getByText("GoFin").closest("a")).toHaveAttribute( + "href", + "/dashboard", + ); + }); + + it("restores identity and navigates to /admin on Return to Admin click", async () => { + resetStore({ isAuthenticated: true, isAssuming: true, user: authenticatedUser }); + const AuthLayout = await importAuthLayout(); + mockFetch .mockResolvedValueOnce({ ok: true, @@ -238,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(); @@ -264,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/__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/auth/__tests__/login.test.tsx b/frontend/apps/shell/app/features/auth/__tests__/login.test.tsx index b6b14832..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"; @@ -40,8 +41,10 @@ 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> }, + { path: "/settings", element: <div>Settings page</div> }, ], }); } @@ -52,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(); @@ -94,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(); @@ -111,31 +121,50 @@ 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(); }); }); + 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(); + + 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(); + }); + }); + 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(); @@ -156,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) => { @@ -169,14 +199,44 @@ 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(); }); }); + + 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 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 }, + }); + + 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(); + + 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(); + }); + expect(screen.queryByText("Admin page")).not.toBeInTheDocument(); + }); }); describe("already-authenticated redirect", () => { @@ -195,5 +255,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(); + }); + }); }); }); 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); }); }); }); diff --git a/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts b/frontend/apps/shell/app/features/auth/hooks/useLoginForm.ts index 9b297164..bb96e4a4 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) => { @@ -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("/dashboard"); + navigate(getLandingPath(loggedInUser)); } }, }); 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/__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(); + } + }); +}); 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..d1c694f2 --- /dev/null +++ b/frontend/apps/shell/app/features/shell-layout/hooks/useAuthLayoutGuards.ts @@ -0,0 +1,80 @@ +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 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" }; + } + 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..2c2ef927 --- /dev/null +++ b/frontend/apps/shell/app/lib/route-access.ts @@ -0,0 +1,50 @@ +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"; + +/** + * 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 + * 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..10cff9cb 100644 --- a/frontend/apps/shell/app/routes/admin-users.tsx +++ b/frontend/apps/shell/app/routes/admin-users.tsx @@ -1,9 +1,12 @@ 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 = 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 5aba4055..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,17 +60,16 @@ function AdminSkeleton() { ); } +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/auth-layout.tsx b/frontend/apps/shell/app/routes/auth-layout.tsx index 6087ad7a..da11becc 100644 --- a/frontend/apps/shell/app/routes/auth-layout.tsx +++ b/frontend/apps/shell/app/routes/auth-layout.tsx @@ -1,25 +1,25 @@ -import { Outlet, NavLink, useNavigate, useLocation } from "react-router"; -import { useAuthStore } from "@/stores/auth-store"; +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"; + +/** + * 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. + */ export default function AuthLayout() { const { user, isAuthenticated, - isAdmin, isAssuming, isLoading, checkAuth, @@ -27,34 +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]); - - // Onboarding redirect guards - const location = useLocation(); - const isOnOnboarding = location.pathname === "/onboarding"; - - useEffect(() => { - if (isLoading || !isAuthenticated || !user) return; - - if (!user.hasCompletedOnboarding && !isOnOnboarding) { - navigate("/onboarding"); - } else if (user.hasCompletedOnboarding && isOnOnboarding) { - navigate("/dashboard"); - } - }, [isLoading, isAuthenticated, user, isOnOnboarding, 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> @@ -62,15 +49,23 @@ export default function AuthLayout() { ); } - if (!isAuthenticated) { - 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"); @@ -86,151 +81,33 @@ 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 }, - ]; - - if (isAdmin) { - navLinks.push({ to: "/admin", label: "Admin", icon: Shield }); - } + // 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 = + 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="/dashboard" 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" /> - - {/* 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> + <Navbar + user={user} + isDirectAdmin={isDirectAdmin} + onLogout={handleLogout} + /> - {/* 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). */} - {!isAssuming && location.pathname !== "/expenses/new" && ( - <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..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,6 +15,8 @@ const DashboardFeature = lazy(() => })), ); +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 0dc23bf5..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,6 +39,8 @@ function ExpenseFormSkeleton() { ); } +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 affde1e2..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,6 +13,8 @@ const ExpenseLogFeature = lazy(() => })), ); +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 f77bc3f7..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,6 +29,8 @@ function HistoryLoadingSkeleton() { ); } +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 2d07ba0b..f5653d7d 100644 --- a/frontend/apps/shell/app/routes/home.tsx +++ b/frontend/apps/shell/app/routes/home.tsx @@ -1,6 +1,13 @@ 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 = accessHandle("authenticated"); -/** 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..0389c6b0 100644 --- a/frontend/apps/shell/app/routes/onboarding.tsx +++ b/frontend/apps/shell/app/routes/onboarding.tsx @@ -1,4 +1,7 @@ import { OnboardingFeature } from "@/features/onboarding"; +import { accessHandle } from "@/lib/route-access"; + +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 84719c2e..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,6 +13,8 @@ const SettingsPage = lazy(() => })), ); +export const handle = accessHandle("authenticated"); + 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", + }, + }, +]; 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"); + }); +}); 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"; +} diff --git a/services/access/access.go b/services/access/access.go new file mode 100644 index 00000000..2b76d9d9 --- /dev/null +++ b/services/access/access.go @@ -0,0 +1,50 @@ +// 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 + // 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. +func (a Access) String() string { + switch a { + case Public: + return "Public" + case Authenticated: + return "Authenticated" + case Personal: + return "Personal" + case Admin: + return "Admin" + case Deny: + return "Deny" + default: + return "Unknown" + } +} 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) {}) +} 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) + } +} 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/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/access/registry.go b/services/access/registry.go new file mode 100644 index 00000000..b19cb6c0 --- /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 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 { + 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..c8cfa308 --- /dev/null +++ b/services/access/registry_test.go @@ -0,0 +1,95 @@ +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 Deny default (403 at the +// gateway). +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 != 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 new file mode 100644 index 00000000..b46fcea3 --- /dev/null +++ b/services/access/resolver.go @@ -0,0 +1,103 @@ +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 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 { + 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 Deny + } + 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..0c07308f --- /dev/null +++ b/services/access/resolver_test.go @@ -0,0 +1,158 @@ +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_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 + 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 != Deny { + t.Errorf("Resolve(%q, %q) = %s, want Deny", 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/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 27ca707a..caeafbeb 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, } } @@ -282,7 +290,9 @@ 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 middleware (GET /api/admin/users is +// classified Admin in the shared services/access registry). func (h *RESTHandler) ListUsers(c *gin.Context) { start := time.Now() @@ -313,7 +323,9 @@ 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 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/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/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() +} 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") + } +} 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, } } 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/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/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/control.go b/services/gateway/internal/access/control.go new file mode 100644 index 00000000..c438b977 --- /dev/null +++ b/services/gateway/internal/access/control.go @@ -0,0 +1,189 @@ +package access + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/ItsThompson/gofin/services/access" +) + +// 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. +// +// 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 via the injected resolver, +// 3. short-circuits Public routes with no token read, +// 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). +func AccessControl(validator TokenValidator, resolve func(method, path string) access.Access, logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + stripIdentityHeaders(c) + + level := resolve(c.Request.Method, c.Request.URL.Path) + if level == access.Public { + 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 == "" { + 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 access.Personal: + if result.Role != roleUser { + rejectForbidden(c, logger, result) + return + } + case access.Admin: + if result.Role != roleAdmin { + rejectForbidden(c, logger, result) + return + } + case access.Authenticated: + // Any valid token passes; no role check. + default: + // 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 + } + + c.Next() + } +} + +// 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). +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 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), + slog.String("path", c.Request.URL.Path), + 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 new file mode 100644 index 00000000..83e561e0 --- /dev/null +++ b/services/gateway/internal/access/control_test.go @@ -0,0 +1,463 @@ +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" + + sharedaccess "github.com/ItsThompson/gofin/services/access" + "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 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.GatewayResolve, 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 + {http.MethodGet, "/api/finance/periods"}, // Personal + {http.MethodPost, "/api/expenses"}, // Personal + {http.MethodPost, "/api/datarights/exports"}, // Personal + {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"]) +} + +// --- 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 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, resolve, 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") +} + +// 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 Deny 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 deny", http.MethodGet, "/api/unknown", sharedaccess.Deny}, + } + + 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 +} + +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 +} 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/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 cf00cad7..00000000 --- a/services/gateway/internal/middleware/auth.go +++ /dev/null @@ -1,108 +0,0 @@ -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 -} - -// 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) -} - -// 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..e036d0f5 100644 --- a/services/gateway/internal/router/router.go +++ b/services/gateway/internal/router/router.go @@ -1,12 +1,15 @@ 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" "github.com/ItsThompson/gofin/services/metrics" @@ -23,7 +26,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,63 +40,46 @@ func New( engine.Use(gin.Recovery()) engine.Use(metrics.HTTPMetrics()) engine.Use(middleware.RequestLogger(logger)) - engine.Use(middleware.Auth(validator, logger)) - - // Prometheus metrics endpoint (excluded from auth middleware via exception list). + // AccessControl is the single global gate: it resolves each route against the + // 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 via GatewayResolve). metrics.Register(engine) - // Health check endpoint: auth middleware skips it via the exception list. + // Health check endpoint (Public via GatewayResolve). engine.GET("/health", func(c *gin.Context) { 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) - - // /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 - adminGroup := engine.Group("/api/admin") - adminGroup.Use(middleware.RequireAdmin(logger)) - { - 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) - // 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)) + // 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 c675ae7e..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,8 +11,10 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - "github.com/ItsThompson/gofin/services/gateway/internal/middleware" + sharedaccess "github.com/ItsThompson/gofin/services/access" + "github.com/ItsThompson/gofin/services/gateway/internal/access" "github.com/ItsThompson/gofin/services/gateway/internal/router" ) @@ -19,13 +22,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 +38,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 +48,7 @@ func adminValidator() *mockValidator { func userValidator() *mockValidator { return &mockValidator{ - result: &middleware.TokenValidationResult{ + result: &access.TokenValidationResult{ UserID: "user-1", Role: "user", Username: "alice", @@ -55,7 +58,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. @@ -151,8 +154,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 +178,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 { @@ -211,7 +214,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 +229,37 @@ 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. +// 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()) + + 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()) @@ -263,10 +297,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) } @@ -277,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) +} 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