From adfdb7ff04a7787b7e9e7780b52cc683ce224b58 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:16:11 +0000 Subject: [PATCH] docs: Apache-2.0 licence and public-repository kit Prepare the repository for public visibility, following the conventions of ovander/ascenda-backend and ovander/ha-vigie. - LICENSE: Apache-2.0 (the same text as ha-vigie). - CONTRIBUTING.md, SECURITY.md (private vulnerability reporting, scope, supported versions), CLAUDE.md, .github/CODEOWNERS, issue forms (bug, feature, config) and a pull-request template. - README: package reference sections for bff and ailang, which had none, with examples compiled and vetted against the module; package and "which package" table rows; Security and License sections. - Fix the Socrate link in README and docs/CLIENT-INTEGRATION.md, which pointed to a repository that does not exist (now ovander/go-oauth2). - Fix the README contributing rule on cross-package imports to match the code (bff/pep -> socrate, aigateway -> ailang). - Move the internal review documents out of the public tree (kept privately); CHANGELOG still lists the fixes with their finding IDs. No Go code changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA --- .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug.yml | 58 +++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature.yml | 20 + .github/pull_request_template.md | 19 + CHANGELOG.md | 18 + CLAUDE.md | 70 ++++ CONTRIBUTING.md | 68 +++ CTO-ARCHITECTURE-REVIEW.md | 469 --------------------- FRAMEWORK-EVOLUTION.md | 641 ----------------------------- LICENSE | 201 +++++++++ README.md | 136 +++++- SECURITY-ARCHITECTURE.md | 572 ------------------------- SECURITY-AUDIT.md | 594 -------------------------- SECURITY.md | 35 ++ docs/CLIENT-INTEGRATION.md | 2 +- 16 files changed, 633 insertions(+), 2280 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/pull_request_template.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md delete mode 100644 CTO-ARCHITECTURE-REVIEW.md delete mode 100644 FRAMEWORK-EVOLUTION.md create mode 100644 LICENSE delete mode 100644 SECURITY-ARCHITECTURE.md delete mode 100644 SECURITY-AUDIT.md create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..40bcb13 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Every change is reviewed by the maintainer. +* @ovander diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..8fbd9ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,58 @@ +name: Bug report +description: A backendkit package does not behave as documented +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. For a security vulnerability, do **not** open an issue: use the + Security tab → Report a vulnerability. + - type: textarea + id: versions + attributes: + label: Versions + description: Output of `go list -m github.com/ovander/backendkit` and `go version`. + render: text + validations: + required: true + - type: dropdown + id: package + attributes: + label: Package + options: + - jwtauth + - bff + - pep + - socrate + - httpware + - tiering + - apierror + - ctxutil + - gormlogger + - aigateway + - ailang + - ainarration + - pagination + - buildinfo + - other / several + validations: + required: true + - type: textarea + id: what + attributes: + label: What happened, and what did you expect? + validations: + required: true + - type: textarea + id: repro + attributes: + label: Minimal reproduction + description: A short Go snippet or test that shows the problem. + render: go + validations: + required: true + - type: textarea + id: extra + attributes: + label: Logs, errors, Socrate version + description: Remove tokens, secrets and personal data before pasting. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..026b5c7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability + url: https://github.com/ovander/backendkit/security/advisories/new + about: Report privately; please do not open a public issue. + - name: Problem with the Socrate server itself + url: https://github.com/ovander/go-oauth2/issues + about: Issues in the identity provider belong in ovander/go-oauth2. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..a04db27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,20 @@ +name: Feature request +description: Suggest an improvement or a new capability +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: The need or the problem, before any solution. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What would help? + description: Including the API you would expect, if you have one in mind. + - type: textarea + id: context + attributes: + label: Anything else (examples, similar libraries) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2b15178 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## What and why + + + +## How it was tested + + + +- [ ] `go build ./...`, `go vet ./...`, `go test -race -count=1 ./...` pass +- [ ] `golangci-lint run ./...` reports no issue +- [ ] `go mod tidy` leaves `go.sum` unchanged +- [ ] A line is added under `## [Unreleased]` in `CHANGELOG.md` + +## Compatibility + + +- Exported API: +- Behaviour change for existing callers: +- Breaking change: diff --git a/CHANGELOG.md b/CHANGELOG.md index 628f75d..4cc99a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ build toolchain. Both purely additive for consumers. ### Added +- **Apache-2.0 licence** (`LICENSE`) and the contributor kit: `CONTRIBUTING.md`, `SECURITY.md` + (private vulnerability reporting, scope, supported versions), `CLAUDE.md`, `CODEOWNERS`, issue + forms and a pull-request template. +- **README package reference for `bff` and `ailang`**, which had none, with examples that compile + against the module. + - **`pep` package** — the policy enforcement point for Socrate's policy decision point. `Enforcer.Middleware` gates a route on an action, `Enforcer.Check` decides object-level inside a handler, `WriteDenial` writes @@ -44,6 +50,18 @@ build toolchain. Both purely additive for consumers. toolchain). v2.5.0 cannot load Go 1.27's export data; v2.14.0 reports no issues on this module. +### Fixed + +- README and `docs/CLIENT-INTEGRATION.md` linked Socrate to a repository that does not exist; + they now point to `ovander/go-oauth2`. The README's contributing rule on cross-package imports + now matches the code (`bff`/`pep` → `socrate`, `aigateway` → `ailang`). + +### Removed + +- The internal review documents (`CTO-ARCHITECTURE-REVIEW.md`, `FRAMEWORK-EVOLUTION.md`, + `SECURITY-ARCHITECTURE.md`, `SECURITY-AUDIT.md`) left the public tree. The fixes they led to + remain listed below with their finding IDs. + ## [1.13.0] - 2026-09-04 Observability slice from the Socrate suite plan (B1): the same RED metric diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5bf31b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md — backendkit + +Standing instructions for Claude Code in this repository. Read this file and `CONTRIBUTING.md` +before any change. The Socrate identity provider lives in `ovander/go-oauth2`; the admin and +monitoring consoles (`ovander/oauth2-admin`, `ovander/oauth2-monitoring`) and applications such +as `ovander/ascenda-backend` import this library, so an API change reaches all of them. + +## Project in one paragraph + +backendkit is the shared Go library of the Socrate suite, a single module +(`github.com/ovander/backendkit`) of small packages: `jwtauth` validates Socrate RS256 access +tokens against the JWKS; `bff` is the Backend-for-Frontend runtime (server-side sessions, +cookies, CSRF, PKCE, the fail-closed session→bearer proxy); `socrate` is the client for the +Socrate OAuth and admin APIs; `pep` enforces Socrate's central policy decisions; `httpware`, +`apierror`, `ctxutil`, `tiering`, `pagination`, `gormlogger` and `buildinfo` are service +plumbing; `aigateway`, `ailang` and `ainarration` wrap AI providers. + +## Sources of truth, in order + +1. The code and its doc comments (`go doc ./`). Read them before proposing changes; do + not describe code you have not opened. +2. `README.md` (package reference) and `docs/CLIENT-INTEGRATION.md` (end-to-end integration with + Socrate). +3. `CHANGELOG.md` for what changed and which review findings (`F-n`, `INV-n`) a change closed. + +## Hard rules + +- **Compatibility.** No breaking change to an exported identifier within `v1`: no removed or + renamed symbol, no changed signature, no stricter default that breaks a working caller without + an opt-in. Additive changes only; a breaking one needs `v2`. +- **Coupling.** Packages may import `ctxutil` and `apierror`. The only other intra-module imports + are `bff`→`socrate`, `pep`→`socrate` and `aigateway`→`ailang`. Do not add another. +- **Fail closed.** Authentication and authorisation paths reject on doubt: no valid session ⇒ + 401, unknown key or algorithm ⇒ reject, policy decision unavailable in enforce mode (or before + the mode is known) ⇒ 503. An opt-out is an explicit, documented option + (`AllowPassthrough`, `FailOpenWhenModeUnknown`), never the zero value. +- **Documentation.** Every exported symbol has a doc comment starting with its name. A new + package gets a package doc comment, a row in the README package tables and a section in the + package reference. +- **Never weaken a gate** to get green: no skipped or deleted tests, no `//nolint` or `t.Skip` + without a one-line reason, no required check removed. +- **Secrets** never enter the repository: no keys, tokens or real client secrets, including in + tests and examples. +- **Scope.** One change per PR; do not widen a PR with unrelated fixes (open a separate one). + +## Local gate (the same checks as CI) + +```bash +go mod tidy && git diff --exit-code go.sum +go build ./... +go vet ./... +go test -race -count=1 -timeout=120s ./... +golangci-lint run ./... # v2.14.0, built with Go 1.27.1 +govulncheck ./... +``` + +CI also fails if the Go version it runs differs from the `toolchain` line in `go.mod`. + +## Git workflow + +- Branch from `main`: `feat/…`, `fix/…`, `chore/…`, `ci/…`, `docs/…`. Conventional Commits. +- Open a PR; never push to `main`, never force-push a shared branch, never merge with red CI. + The owner merges. +- Each PR adds a line under `## [Unreleased]` in `CHANGELOG.md`, and says in its body what it + changes, how it was tested, and any change to the exported API. + +## Releases (the owner runs them) + +A release is a tag `vX.Y.Z` on `main`, with the `[Unreleased]` changelog section moved under the +new version. The `Release` workflow publishes the GitHub release. Do not tag unless asked. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e16b5fa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing to backendkit + +Thank you for your interest. backendkit is the shared Go library of the Socrate suite: it lets a +Go service authenticate users against the Socrate OAuth 2.1 / OIDC provider +([`ovander/go-oauth2`](https://github.com/ovander/go-oauth2)), run a Backend-for-Frontend, and +enforce Socrate's policies. Contributions are accepted under the project's licence, +[Apache-2.0](LICENSE). + +## Development setup + +Requirements: Go (the `toolchain` line in `go.mod` downloads the exact version, 1.27.1). The +tests need no database and no network service. + +```bash +git clone https://github.com/ovander/backendkit && cd backendkit +go mod download +go test ./... +``` + +## Design rules + +- One package per directory, each with a package doc comment. +- Packages stay loosely coupled. Every package may use the shared primitives `ctxutil` and + `apierror`. Beyond those, only deliberate layering is allowed: `bff` and `pep` build on + `socrate`, and `aigateway` builds on `ailang`. Do not add a new cross-package import without + discussing it first. +- Every exported symbol has a doc comment that begins with its name. Runnable examples go in + `example_test.go`; they appear on pkg.go.dev. +- Security-relevant behaviour fails closed by default (for example, `bff.Gateway` answers 401 + without a valid session instead of passing the request through). An opt-out must be an + explicit, documented option. +- No breaking change to an exported API within a major version. A breaking change needs a new + major version and import path (`github.com/ovander/backendkit/v2`). + +## Tests and checks + +Run these before opening a pull request; CI runs the same and all of them are required: + +```bash +go mod tidy && git diff --exit-code go.sum +go build ./... +go vet ./... +go test -race -count=1 -timeout=120s ./... +golangci-lint run ./... # v2.14.0, built with Go 1.27.1 +govulncheck ./... +``` + +- Tests sit next to the code (`*_test.go`), table-driven. +- A bug fix comes with a test that fails without it. + +## Pull requests + +1. Branch from `main` (`feat/…`, `fix/…`, `chore/…`, `docs/…`). +2. Commit with [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, + `chore:`, `docs:`, `ci:`, `test:`). +3. Add a line under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). +4. Open the PR with the template filled in, including any change to the exported API. +5. CI must be green. The maintainer reviews and merges. + +## Releases + +The maintainer tags releases `vX.Y.Z` on `main`. The `Release` workflow then publishes the GitHub +release with notes built from the commits since the previous tag. Consumers pin an explicit +version in their `go.mod`. + +## Security + +Please do not open a public issue for a vulnerability. See [SECURITY.md](SECURITY.md). diff --git a/CTO-ARCHITECTURE-REVIEW.md b/CTO-ARCHITECTURE-REVIEW.md deleted file mode 100644 index 9c7065d..0000000 --- a/CTO-ARCHITECTURE-REVIEW.md +++ /dev/null @@ -1,469 +0,0 @@ -# Final Pass — CTO Architecture Review - -**Subject:** `backendkit` as a platform dependency, projected to: 50 teams, -300 developers, hundreds of SaaS products, thousands of services. -**Lens:** CTO accountable for the next five years. **Not** security, **not** -bugs, **not** style. One question only: - -> If we keep building on backendkit for five years, which architectural decisions -> made *today* become tomorrow's technical debt — and how do we maximise the -> framework's **architectural half-life**? - -> **Status note (post v1.8.0, 2026-06-20).** None of the load-bearing decisions in -> this review (D1 single module, D2 personal import path, D3 logrus-in-API, D4 -> HTTP-only transport, D5 flat identity, D6 unversioned error envelope, …) have -> changed — they remain the long-term debt to address in v2.0. v1.8.0 shipped -> security hardening only. Two small data points relevant here: the build now pins -> the Go 1.26.4 `toolchain` (touches D1's dependency-closure surface), and the -> project demonstrated clean SemVer + changelog release discipline cutting v1.8.0 -> (the D-governance trait this review flagged as durable). - -The governing law of this review is **Hyrum's Law**: at thousands of services, -every observable behaviour — every import path, every JSON field, every -zero-value fallback, every transitive dependency version — becomes a contract -someone depends on. The cost of changing a decision scales with the number of -consumers, not the size of the change. So the debt that matters is not "what is -wrong" but "what is **load-bearing and hard to reverse**." - ---- - -## The Master Decision: bundling components of radically different half-life - -Before the individual decisions, the strategic one that frames them all. - -`backendkit` is a **single Go module** (`module github.com/ovander/backendkit`) -that bundles, under one version and one dependency closure, components whose -*natural half-lives differ by one to two orders of magnitude*: - -| Component | What it tracks | Natural half-life | -|-----------|----------------|-------------------| -| `apierror`, `ctxutil`, `pagination` | language + HTTP semantics | **~decade** | -| `httpware` | `net/http` middleware shape | ~5–10 yr | -| `jwtauth` | JWT/JWKS standards | ~5 yr | -| `tiering` | *a* product's billing model | ~2–3 yr | -| `socrate` | one IdP's private API (dual-port :8080/:8081) | ~1–2 yr | -| `aigateway` | OpenAI/Anthropic wire formats | **~6–12 mo** | -| `ainarration` | a product feature | months | - -**Putting a 6-month-half-life component (`aigateway`) in the same versioned, -co-released unit as a 10-year component (`apierror`) is the root architectural -debt.** It forces one of two failures: either the stable core is dragged into -churn every time OpenAI changes a field, or the fast-moving clients ossify to -avoid breaking the core's SemVer promise. Every decision below is a special case -of this: *the framework has not separated platform primitives (stable, universal) -from product services (volatile, opinionated).* - -Everything that follows is ranked by **reversal cost at scale**. - ---- - -## D1 — Single module / one version / one dependency closure - -**Decision today.** One `go.mod`; `apierror` (zero real deps) and `socrate`/ -`tiering` (gorm, jwt, logrus) ship as one importable unit with one version line. - -**Why it becomes painful.** Three compounding failures at scale: -1. *Imposed dependency graph.* Every service that imports `apierror` inherits the - framework's `gorm`/`jwt`/`logrus` versions in its build. A team that needs a - newer gorm than the framework pins gets a diamond conflict — the framework - dictates dependency versions for thousands of services (the Terraform-provider - problem). -2. *Coupled release cadence.* A breaking change in `aigateway` forces a major-version - bump of the whole module, which by SemVer changes the import path - (`/v2`) for `apierror` too — a package that did not change. -3. *Blast radius.* One CVE or one API break anywhere is a fleet-wide event. - -**When.** Begins hurting at ~dozens of services; acute once the fast-moving -clients (`aigateway`, `socrate`) iterate on their own schedule — **12–24 months**. - -**Cost to change later.** **Very high and super-linear.** Splitting modules after -thousands of import sites means a coordinated import-path migration across every -service (see D2). Early: a week of repo plumbing. Late: a multi-quarter, -multi-team migration program. - -**Change before v2.0?** **Yes — this is the one that must land in v2.0.** Module -boundaries are the single least-reversible decision in Go. - -**Migration strategy.** Multi-module repo + `go.work`: `backendkit` (core, -std-lib + uuid), `backendkit/auth`, `backendkit/tiering`, -`backendkit/integrations/{socrate,ai,gorm}`, `backendkit/otel`. Synchronised -majors, independent minors. - -**Compatibility strategy.** Keep v1 as a thin meta-module that re-exports the new -locations via type aliases for one major, so existing imports compile during the -window. - -**Alternatives.** (a) Stay monolithic and accept imposed deps — viable only for an -internal library, fatal for a platform. (b) Two modules (core vs. everything-else) -— half the benefit, half the work; a reasonable compromise if full decomposition -is too costly. - -**Trade-offs.** Multi-module versioning is operational overhead (release matrix, -`go.work`); the payoff is that `import apierror` becomes free and each layer -versions on its own clock. - ---- - -## D2 — The import path is a personal namespace (`github.com/ovander/...`) - -**Decision today.** The module identity is a single person's GitHub account. - -**Why it becomes painful.** The import path *is* the public API in Go — it is -interned into every file of every consumer. A personal namespace signals -single-owner risk to 50 adopting teams, ties the framework's identity to an -individual account's lifecycle, and is the hardest thing to change later because -**every `.go` file in thousands of services names it**. - -**When.** A governance/perception problem from day one of broad adoption; a -*mechanical* problem the moment you want to move it — and that only gets worse. - -**Cost to change later.** **The single most expensive refactor in Go.** Changing -an import path touches every consumer simultaneously; there is no gradual path -without a compatibility shim module. Cost grows strictly with adoption. - -**Change before v2.0?** **Yes — do it with the v2 module split (D1), once.** Never -pay an import-path migration twice. - -**Migration strategy.** Adopt a vanity/org path (`go..dev/backendkit` or a -neutral foundation-style domain) using `go-import` meta tags so the canonical path -is decoupled from the hosting provider forever after. Bundle the rename into the -v2 cutover so teams absorb one migration, not two. - -**Compatibility strategy.** Publish the old path as a deprecated alias module that -type-aliases to the new one for one major version. - -**Alternatives.** Keep the personal path — only defensible if the framework will -never outlive or exceed its author, which contradicts the premise. - -**Trade-offs.** A vanity domain needs a tiny redirect service/hosting; the cost is -trivial against the value of a stable, ownership-neutral identity. - ---- - -## D3 — A specific logging library (logrus) in the public API - -**Decision today.** logrus types appear in constructors across the framework and -`ctxutil` stores/returns logrus entries — the logger *is* part of the contract. - -**Why it becomes painful.** It dictates the logging stack of 300 developers, and -it bet on a library now in maintenance mode while the standard library shipped -`log/slog`. A logging choice baked into a *public signature* has a short half-life -and a wide blast radius: every consumer writes against logrus types, so the -ecosystem's move to slog is blocked by the framework. - -**When.** Already underway; acute within **12 months** as slog becomes the default -everywhere and new hires expect it. - -**Cost to change later.** **Medium-high but unavoidable.** Every signature and -every call site changes. Cheaper than D1/D2 (it's leaf-level, mechanical) but -touches a lot of surface. - -**Change before v2.0?** **Yes.** Logging is a contract; flip it at the major. - -**Migration strategy.** Move all signatures to `*slog.Logger`; ship a logrus→slog -`slog.Handler` adapter so teams on logrus keep their pipelines unchanged. - -**Compatibility strategy.** v1.x adds slog-accepting variants alongside logrus and -deprecates the latter; v2 removes logrus. - -**Alternatives.** Define a minimal internal `Logger` interface instead of binding -to slog — maximal decoupling, but reinvents what slog standardised; only worth it -if you must support pre-1.21 toolchains (you don't, given Go 1.25). - -**Trade-offs.** slog's attribute semantics differ slightly from `WithField`; the -adapter and a mapping doc absorb that. - ---- - -## D4 — Transport coupling: `net/http` is the only first-class citizen - -**Decision today.** The framework's spine is `func(http.Handler) http.Handler`; -auth, RBAC, tiering, rate limiting are all HTTP-middleware-shaped. - -**Why it becomes painful.** Across thousands of services, a meaningful fraction -will be gRPC / ConnectRPC / event-driven / queue consumers. None of them can reuse -`jwtauth`, `RBAC`, or `tiering` because those are welded to `http.Handler`. Teams -will re-implement auth and authz per transport — exactly the duplication the -framework exists to prevent. **Kratos and go-kit abstract transport for precisely -this reason.** - -**When.** The day the second transport matters — **2–3 years** as the service mesh -diversifies. - -**Cost to change later.** **High.** The *logic* (verify token → principal → -authorize) is sound but entangled with `http.ResponseWriter`. Re-seating it onto a -transport-neutral core after thousands of HTTP call sites exist is a deep refactor. - -**Change before v2.0?** **Partially.** Don't build gRPC now, but **extract the -transport-neutral core in v2** so HTTP becomes one adapter, not the substrate. - -**Migration strategy.** Factor each control into a pure core (`Verify(ctx, raw) -→ Principal`, `Authorize(ctx, principal, request) → Decision`) with thin -`net/http` adapters. gRPC interceptors and queue middleware become additional -adapters over the same core, later. - -**Compatibility strategy.** The existing HTTP middleware keeps its exact signatures -as the first adapter — no consumer change. - -**Alternatives.** Stay HTTP-only — acceptable if the org is forever HTTP; a real -bet against five years of transport evolution. - -**Trade-offs.** One layer of indirection between core and transport; the upside is -write-auth-once across every protocol. - ---- - -## D5 — Identity modelled as flat, optional, zero-value context getters with IdP semantics in core - -**Decision today.** Identity is a set of independent context values -(`GetUserID`, `GetTenantID`, `GetUserRole`, `GetUserPlan`, …) that **return -zero-values when absent** (`uuid.Nil`, `""`, `"freemium"`) with no error, and the -claim *names and semantics are Socrate's*, embedded in core `ctxutil` and `jwtauth`. - -**Why it becomes painful.** Two distinct debts: -1. *No `Principal` concept.* Identity is a bag of scalars, so the model cannot grow - to express **impersonation / on-behalf-of, service identity vs. user identity, - delegated tenancy, or multiple simultaneous roles** — all of which large - multi-tenant orgs eventually need. Adding them later means changing the meaning - of getters that thousands of services already call. -2. *Zero-value fallbacks are Hyrum landmines.* `GetUserPlan → "freemium"` and - `GetTenantID → uuid.Nil` make "absent" indistinguishable from "present and - default." Thousands of services will encode "if plan == freemium" logic that - silently fires on *missing* identity. You can never tighten this to return an - error without breaking them. -3. *Socrate's identity model is the framework's identity model.* Some of 50 teams - will not use Socrate (acquisitions, partner SSO, standard OIDC). The core - shouldn't know what `app_roles` or `plan` mean. - -**When.** The first impersonation/multi-identity requirement, or the first non-Socrate -IdP — **18 months to 3 years**. - -**Cost to change later.** **High.** Identity is referenced everywhere; reshaping it -is a fleet-wide change. The zero-value contract in particular is effectively frozen -by Hyrum once products depend on it. - -**Change before v2.0?** **Yes — introduce a `Principal` and move IdP semantics to an -adapter in v2.** This is core and rarely revisited. - -**Migration strategy.** Define an opaque `Principal` carried as one context value; -the existing scalar getters become convenience accessors over it. Socrate claim -mapping moves to a `socrate` adapter. New, explicit "present?" APIs -(`Principal.Tenant() (uuid.UUID, bool)`) coexist with the old zero-value getters. - -**Compatibility strategy.** Keep the flat getters (deprecated) reading from the -`Principal`; both work through the v2 window. - -**Alternatives.** Keep scalars and add ad-hoc keys per new need — the status quo, -which accretes the debt rather than resolving it. - -**Trade-offs.** A `Principal` is slightly more ceremony for the common case; it buys -an identity model that can evolve without rewriting consumers. - ---- - -## D6 — The JSON error envelope is an unversioned cross-boundary wire contract - -**Decision today.** `apierror` emits `{"error":{"code":"...","message":"...", -"key":"...","details":...}}` with a fixed set of `code` strings. - -**Why it becomes painful.** This shape crosses the framework boundary into **every -frontend and every consuming service**. By Hyrum's Law it ossifies the instant the -first frontend switches on `error.code == "validation_error"`. Yet it has **no -schema, no version field, and no content negotiation**. It is simultaneously the -most depended-upon contract in the framework and the least formally governed. The -day you need to evolve it (RFC 9457 `application/problem+json`, nested errors, -multi-error responses, machine-readable field paths) you cannot, because thousands -of clients parse the current shape exactly. - -**When.** It is *already* frozen the moment of broad adoption; the pain arrives when -you first need to change it — **any time**, and you'll find you can't. - -**Cost to change later.** **High and externalised** — the breakage lands on -frontend teams and API consumers you may not even control (partners, public APIs). - -**Change before v2.0?** **Yes — stabilise it deliberately now**, while you still -can choose the shape, rather than discovering the accidental one is permanent. - -**Migration strategy.** Adopt a versioned, standards-aligned envelope -(RFC 9457 problem+json) behind content negotiation, register the `code` vocabulary -as an explicit, documented enum, and treat it as a published API with its own -compatibility policy. - -**Compatibility strategy.** Emit the legacy shape by default; opt into the new -shape via `Accept`/media-type so old and new clients coexist indefinitely. - -**Alternatives.** Freeze the current shape forever and only ever *add* optional -fields (never remove/rename) — a legitimate, low-effort strategy *if you commit to -it explicitly today*. The danger is doing this by accident instead of by decision. - -**Trade-offs.** Content negotiation adds a little complexity; it is the only way to -evolve a contract with uncontrolled consumers. - ---- - -## D7 — Business semantics embedded in shared infrastructure (the tier model) - -**Decision today.** A commercial three-tier model (`freemium`/`pro`/`enterprise`) -and a `"freemium"` default live inside shared libraries (`tiering`, and a default -baked into `ctxutil`). - -**Why it becomes painful.** Hundreds of *different* SaaS products will not share one -billing model — seats, usage-based, per-feature entitlements, free-trial states, -non-commercial internal tools. A generic platform that assumes a specific monetisation -shape fights every product that monetises differently, and the `"freemium"` default -silently misclassifies any product that doesn't use that word. - -**When.** The second distinct billing model in the portfolio — **12–24 months**. - -**Cost to change later.** **Medium.** `tiering` is already partly abstracted -(`PlanRegistry`, `PolicyRepository`), so the debt is the *defaults and placement*, -not the whole design. - -**Change before v2.0?** **Yes, cheaply** — relocate business defaults out of core. - -**Migration strategy.** Move the tier vocabulary and the `"freemium"` fallback into -a product-supplied policy/adapter; core ships only the *mechanism* (ordered -registry, entitlement check), never a *policy*. Reframe `tiering` as a generic -"entitlements" engine, with tiers as one configuration of it. - -**Compatibility strategy.** Ship the current three-tier registry as a provided -default config, not a core constant. - -**Alternatives.** Leave it — fine for a single-product company, wrong for a -multi-product platform. - -**Trade-offs.** Slightly more setup per product; the platform stops encoding one -team's pricing. - ---- - -## D8 — In-process, in-memory state with no distributed-state seam - -**Decision today.** Rate-limit buckets, JWKS cache, and policy cache are -process-local maps. The architectural assumption is "one process, local state." - -**Why it becomes painful.** At thousands of autoscaled, multi-region services, -process-local state means rate limits multiply by replica count, caches can't be -invalidated fleet-wide, and there is no seam for a coordinated control plane. The -debt is not the in-memory *implementation* — that's a fine default — it's the -**absence of a store interface**, which forces 50 teams to each fork or work around -the stateful components. - -**When.** First multi-region or aggressive-autoscaling workload — **18 months–3 yr**. - -**Cost to change later.** **Medium.** Retrofitting an interface under a concrete -impl is mechanical; the cost is the call sites that assumed local semantics. - -**Change before v2.0?** **Yes — introduce the interfaces** (keep in-memory as the -default adapter). Interfaces are cheap now, expensive to insert after forks -proliferate. - -**Migration strategy.** Define `RateStore` / `KeySetCache` (and a `KeyFunc` for the -limiter key) with `memory.*` defaults and `redis.*` adapters in a separate module. - -**Compatibility strategy.** Default to the in-memory adapter so current behaviour is -byte-for-byte unchanged unless a store is supplied. - -**Alternatives.** Stay in-memory — guarantees every team builds their own -distributed limiter, defeating the framework's purpose. - -**Trade-offs.** An interface boundary on a hot path (must support atomic -check-and-decrement); negligible cost for correctness at scale. - ---- - -## D9 — Fleet governance by convention, not by a control-plane seam - -**Decision today.** The "correct" middleware stack and its order are documented in -prose and assembled by each service's own `r.Use(...)` calls. - -**Why it becomes painful.** With 300 developers, convention drifts: every service -wires its own stack. When the org must push a *mandatory* change across the fleet — -a new compliance header, a new tracing requirement, a kill-switch — **there is no -seam to do it.** You file 2,000 PRs. A platform at this scale needs a single, -versioned "standard stack" object that services adopt by reference, so the platform -team can evolve the fleet centrally. - -**When.** The first org-wide mandate — **12–18 months**. - -**Cost to change later.** **Medium**, but recurring: every un-seamed mandate is a -fleet-wide manual campaign. - -**Change before v2.0?** **Yes, additively** — ship a composed, versioned -`RecommendedStack` that teams adopt as one line. - -**Migration strategy.** Provide a `Stack`/`Chain` value encoding the standard order; -services call `stack.Then(handler)`. Platform updates flow by bumping the stack. - -**Compatibility strategy.** Purely additive; raw `r.Use` keeps working. - -**Alternatives.** Linting/policy-as-code to enforce ordering — complementary, but no -substitute for a single point of evolution. - -**Trade-offs.** Centralising the stack trades per-team flexibility for fleet -governability — exactly the trade a platform should make. - ---- - -## Consolidated Debt Ledger - -| # | Decision | Pain onset | Reversal cost @ scale | Land in v2.0? | -|---|----------|-----------|------------------------|---------------| -| D1 | Single module / one dep closure | 12–24 mo | **Very high** | **Must** | -| D2 | Personal import path | day one / on move | **Highest (mechanical)** | **Must** | -| D5 | Flat identity + IdP-in-core | 18–36 mo | High | **Should** | -| D4 | HTTP-only transport | 24–36 mo | High | Extract core; full later | -| D6 | Unversioned error wire contract | already frozen | High (externalised) | **Should (stabilise)** | -| D3 | logrus in public API | <12 mo | Medium-high | **Should** | -| D8 | In-memory state, no store seam | 18–36 mo | Medium | Seam now | -| D7 | Business model in core | 12–24 mo | Medium | Cheap now | -| D9 | Governance by convention | 12–18 mo | Medium (recurring) | Additive now | - -**Rule of thumb for sequencing:** the **irreversible packaging decisions (D1, D2) -must be made before v2.0**, because their cost is the only one that grows without -bound. The **seams (D4, D5, D8, D9)** should be *opened* in v2.0 even if the second -implementation comes later — inserting an interface is cheap before forks exist and -expensive after. The **contracts (D3, D6, D7)** should be *deliberately chosen* now -rather than allowed to ossify by accident. - ---- - -## What decisions made today will still look correct in 2035? - -Not everything here is debt. Several decisions have a long half-life *because they -bound the framework to durable, external standards rather than to fashions* — and -they should be **protected** through every migration above: - -1. **The standard middleware signature `func(http.Handler) http.Handler`.** It is - the `net/http` lingua franca; aligning to the platform interface (rather than a - bespoke one) means the HTTP layer composes with the entire Go ecosystem. Even - when transport is abstracted (D4), keeping this as *the HTTP adapter's* shape is - correct for a decade. -2. **Unexported, typed context keys accessed only through helpers.** The decision to - make context keys collision-proof and private is correct forever; the only change - is *what* they carry (a `Principal`), not *how* they're keyed. -3. **Structured, typed errors that separate machine `code` from human `message` - from i18n `key`.** The separation is exactly right and ages well; only the - *envelope versioning* (D6) needs governance. -4. **Configuration injected as values; no environment reads inside packages.** This - 12-factor discipline is timeless and is what makes the whole framework testable - and embeddable. -5. **Interfaces at the data boundary (the `PolicyRepository` pattern).** The one - place the framework already inverted a dependency is the template for everything - else; that instinct was correct and should be generalised, not undone. -6. **A small, reputable, std-lib-leaning dependency set, and pure middleware with no - shared mutable request state.** Minimalism is the single best predictor of long - half-life; resist every temptation to grow the core's dependency surface. -7. **Deprecation discipline via documented aliases.** The process already practised - in the codebase is precisely how you keep faith with thousands of consumers - across majors; institutionalise it (D-governance) rather than leaving it ad hoc. - -**The through-line:** the decisions that will still look correct in 2035 are the ones -that **coupled backendkit to enduring standards — `net/http`, `context`, `log/slog`, -HTTP status semantics, dependency injection — and the ones that kept the core small.** -The decisions that will become debt are the ones that **coupled it to a moment — -a personal namespace, one logging library, one IdP, one billing model, one -transport, and one big module that fuses all of them.** Maximising the framework's -architectural half-life is, in the end, a single move repeated everywhere: **push -every "moment" out to an adapter, keep only the "standards" in the core, and version -them on separate clocks.** diff --git a/FRAMEWORK-EVOLUTION.md b/FRAMEWORK-EVOLUTION.md deleted file mode 100644 index 33e3f42..0000000 --- a/FRAMEWORK-EVOLUTION.md +++ /dev/null @@ -1,641 +0,0 @@ -# Fifth Pass — Framework Evolution Review (backendkit → v2.0 and beyond) - -**Subject:** `github.com/ovander/backendkit` (single module, Go 1.25, commit `46105b1`) -**Lens:** Principal engineer preparing a v2.0 and a five-year roadmap. -**Scope:** long-term framework quality only. No security-defect hunting; security -findings from `SECURITY-AUDIT.md` / `SECURITY-ARCHITECTURE.md` are referenced only -where they shape an API decision. -**Deliverable:** RFC-style proposals, each with Motivation, Current design, -Proposed design, Migration, Backward compatibility, Breaking changes, Risk, -Effort, Expected benefits. - -> **Status note (post v1.8.0, 2026-06-20).** The RFCs below (slog, module split, -> auth/authz abstraction, …) are **still all open** — v1.8.0 was a *security -> hardening* release, not a structural one. The one overlap: RFC-003's intent that -> "audience/revocation become first-class options" was partly down-paid by the -> opt-in `jwtauth.WithAudience` (#7) and `jwtauth.WithRevocationCheck` (#17), -> which are option-shaped seams the future `TokenVerifier`/`RevocationChecker` -> interfaces can absorb. No RFC here is yet implemented. - ---- - -## North-Star Thesis - -backendkit today is a **Socrate-coupled, logrus-coupled, single-module toolkit**. -Every concrete strength it has — clean middleware, typed context, fail-closed -gates — is wrapped around two hard assumptions that a general framework cannot -make: *"the IdP is Socrate"* and *"the logger is logrus."* The v2.0 program is, in -one sentence: **turn the concrete toolkit into a set of small interfaces with -Socrate/logrus/gorm as the default adapters, split along module lines so a -consumer pays only for what it imports.** - -The RFCs below are ordered by leverage. RFC-001 and RFC-002 are foundational; -the rest depend on them. - ---- - -## RFC Index - -| RFC | Title | Tier | Breaking? | -|-----|-------|------|-----------| -| 001 | Logging abstraction: adopt `log/slog`, drop the logrus dependency | Foundational | Yes (v2) | -| 002 | Module decomposition: core vs. integration modules | Foundational | Yes (import paths) | -| 003 | Authentication abstraction: `TokenVerifier` / `ClaimsMapper` / `Principal` | Core | Yes (v2) | -| 004 | Authorization abstraction: `Authorizer` interface + object-level/ABAC | Core | Additive→default-flip | -| 005 | Identity & context: generic typed context, drop Socrate-specific keys | Core | Yes (v2) | -| 006 | Middleware composition: a router-agnostic `Chain`/`Stack` | Core | Additive | -| 007 | Configuration model: functional options + `Validate()` everywhere | Core | Additive→default-flip | -| 008 | Generics: pagination, response envelopes, typed handlers, context store | Quality | Additive | -| 009 | Event system: security/audit event bus with observers | Capability | Additive | -| 010 | Observability: slog + OpenTelemetry traces & metrics | Capability | Additive | -| 011 | Pluggable stores: rate-limit / JWKS / audit-sink interfaces | Extensibility | Additive | -| 012 | Testing infrastructure: doubles, fixtures, conformance suite | Quality | Additive | -| 013 | SemVer, deprecation & release governance | Process | n/a | - ---- - -## RFC-001 — Logging abstraction (`log/slog`) - -**Motivation.** logrus is in maintenance mode and is a *hard, transitive* -dependency forced on every consumer. The framework leaks it through public -signatures (`jwtauth.New(..., *logrus.Entry)` `middleware.go:92`; -`httpware.Logger(*logrus.Logger)` `logger.go:31`; `Recover(*logrus.Entry)` -`recover.go:14`; `ctxutil` imports logrus `context.go:17`; `tiering`, `aigateway`, -`gormlogger`, `socrate` all the same). A consumer standardised on zap or slog -cannot use backendkit without dragging in logrus. - -**Current design.** logrus types appear in 9+ exported signatures and in -`ctxutil.GetLogger`/`WithLogger` (`context.go:204-218`). The logger *is* the -public contract. - -**Proposed design.** Depend only on the standard library `log/slog` (`*slog.Logger`). -Every signature that takes `*logrus.Entry`/`*logrus.Logger` takes `*slog.Logger`. -`ctxutil` stores/returns `*slog.Logger`. Ship `backendkit/adapters/logrusslog` -(a `slog.Handler` that writes to an existing logrus pipeline) so teams still on -logrus lose nothing. - -**Migration.** v1.x: add `*slog.Logger`-accepting variants (`jwtauth.NewWithSlog`, -etc.) alongside the logrus ones; mark logrus variants `// Deprecated`. v2.0: remove -logrus variants; provide the logrus→slog adapter. - -**Backward compatibility.** Source-breaking at v2 only; the v1.x window gives a -no-rush path and the adapter preserves output formatting. - -**Breaking changes.** All logger parameter types change at v2. - -**Risk.** Low-medium. slog field semantics differ slightly from logrus -`WithField`; mitigated by the adapter and a field-mapping doc. - -**Effort.** M (≈1–2 weeks). Mechanical across packages + one adapter + tests. - -**Expected benefits.** Zero forced logging dependency; std-lib idiom; smaller -dependency graph; instant compatibility with any `slog.Handler` (zap, zerolog, -OTel logs). - ---- - -## RFC-002 — Module decomposition - -**Motivation.** One module means importing `apierror` (190 LoC, zero deps) -transitively offers `gorm`, `golang-jwt`, `x/time`, and `logrus` to the build -graph. The "core" primitives and the "integration" clients have wildly different -dependency footprints and release cadences. - -**Current design.** Single `go.mod` (`module github.com/ovander/backendkit`) with -gorm + jwt + logrus + uuid + x/time as direct deps, consumed wholesale. - -**Proposed design.** Split into a small set of modules under one repo -(multi-module repo + `go.work` for development): - -``` -backendkit/ (core; std-lib + uuid only) - apierror ctxutil httpware pagination buildinfo -backendkit/auth (golang-jwt) — jwtauth, authz interfaces -backendkit/tiering (gorm) -backendkit/integrations/socrate (Socrate adapter) -backendkit/integrations/gorm (gormlogger) -backendkit/integrations/ai (aigateway) -backendkit/otel (OTel adapters, RFC-010) -``` - -Core depends on nothing heavy; each integration pulls only its own client. - -**Migration.** Import paths change per moved package; provide a `go fix`-style -sed map and a `MIGRATION.md`. Tag all modules in lock-step for v2.0.0. - -**Backward compatibility.** Import paths break (the only mechanical break in this -RFC). Behaviour unchanged. - -**Breaking changes.** New import paths for everything outside core. - -**Risk.** Medium — multi-module versioning discipline (each module needs its own -tag `auth/v2.0.0`, etc.); CI must build the workspace. - -**Effort.** M-L (≈2–3 weeks incl. CI/release retooling). - -**Expected benefits.** `import apierror` costs nothing; gorm/jwt only where used; -independent release cadence; clearer ownership; smaller blast radius per CVE. - ---- - -## RFC-003 — Authentication abstraction - -**Motivation.** `jwtauth` is hardwired to Socrate: `SocrateClaims` -(`middleware.go:45-60`), RS256-only, JWKS-only, Socrate's `sub`/`role`/`plan`/ -`app_roles` semantics baked into the middleware body (`middleware.go:120-169`). A -framework must support other IdPs (Auth0, Cognito, Keycloak, Okta), opaque-token -introspection, and HS/ES algorithms — and, per the security pass, **audience and -revocation** belong at this seam. - -**Current design.** One concrete `*Middleware` struct doing extraction + -verification + Socrate claim→context mapping in a single method. - -**Proposed design.** Decompose into interfaces: - -```go -type Principal interface { // replaces scattered ctx getters - Subject() string - Tenant() (uuid.UUID, bool) - Roles() []string - Claim(name string) (any, bool) -} -type TokenVerifier interface { // verification only - Verify(ctx context.Context, raw string) (Claims, error) -} -type ClaimsMapper interface { // claims → Principal (IdP-specific) - Map(Claims) (Principal, error) -} -type RevocationChecker interface{ Live(context.Context, Claims) (bool, error) } -``` - -`auth.Middleware(verifier, mapper, opts...)` composes them. Ship: -`jwks.RS256Verifier` (today's logic, plus required `aud`/min-key-size hooks), -`introspect.Verifier` (RFC 7662), and `socrate.Mapper` (the current Socrate -semantics, now an *adapter*, not the core). - -**Migration.** v1.x: add the interfaces and a `socrate.Mapper` that reproduces -current behaviour; `jwtauth.New` delegates to them internally (no behaviour -change). v2.0: `jwtauth.New` is deprecated in favour of `auth.Middleware`. - -**Backward compatibility.** Fully preserved through v1.x via the Socrate adapter. - -**Breaking changes.** v2 removes the Socrate-specific exported claim struct from -core auth; it lives in the Socrate integration module. - -**Risk.** Medium — the seam must not regress the (correct) alg-pinning at -`middleware.go:193,199-201`; conformance tests (RFC-012) guard this. - -**Effort.** L (≈3–4 weeks). - -**Expected benefits.** Multi-IdP support; audience/revocation become first-class -options; verifier and mapper independently testable and swappable. - ---- - -## RFC-004 — Authorization abstraction - -**Motivation.** Authorization today is a flat role→permission map -(`RoleMap`/`hasPermission` `rbac.go:25,57-67`) and a plan tier (`tiering`). There -is no object-level / ownership / ABAC seam, and authz is opt-in per route. A -framework needs a single `Authorizer` contract that RBAC, plan-gating, and -object-level checks all implement and that can be *composed*. - -**Current design.** `RBAC.Require(perm)` and `Gate.Require(plan)` are two separate -concrete middlewares reading two separate context values. - -**Proposed design.** - -```go -type Decision int // Allow, Deny, Abstain -type Authorizer interface { - Authorize(ctx context.Context, p Principal, req Request) (Decision, error) -} -// Combinators: -func All(...Authorizer) Authorizer // AND -func Any(...Authorizer) Authorizer // OR -func Require(Authorizer) Middleware // 403 on Deny/Abstain (fail-closed) -``` - -`rbac.New(roleMap)`, `tiering.PlanGate(reg)`, and a new -`obj.OwnerOf(func(ctx) (ownerID, error))` all return `Authorizer`. Object-level -authz (the OWASP API1/BOLA gap) finally has a home. - -**Migration.** v1.x: introduce `Authorizer`; make existing `RBAC`/`Gate` implement -it while keeping their current `Require` methods. v2.0: unify under -`authz.Require`. - -**Backward compatibility.** Existing `RBAC.Require`/`Gate.Require` keep working -through v1.x and can remain as thin wrappers in v2. - -**Breaking changes.** None required until a v2 cleanup that collapses duplicate -middleware. - -**Risk.** Low-medium. Combinator semantics (Abstain vs Deny) must be specified -precisely; default is fail-closed. - -**Effort.** M (≈2–3 weeks). - -**Expected benefits.** Composable, testable authz; object-level support; one -mental model for role, plan, and ownership checks. - ---- - -## RFC-005 — Identity & context (generics) - -**Motivation.** `ctxutil` hardcodes a fixed, Socrate-shaped set of keys -(`tenantIDKey`…`rawJWTKey` `context.go:26-39`) with one bespoke getter/setter -pair each (≈12 pairs). Adding a claim means editing the core package. The -`GetUserPlan→"freemium"` default (`context.go:101-106`) bakes a business default -into a generic library. - -**Current design.** ~250 lines of near-identical `With*/Get*` helpers over -unexported string keys. - -**Proposed design.** A generic typed-context primitive plus a `Principal` stored -once: - -```go -type Key[T any] struct{ name string } -func NewKey[T any](name string) Key[T] -func With[T any](ctx context.Context, k Key[T], v T) context.Context -func Value[T any](ctx context.Context, k Key[T]) (T, bool) -``` - -Core ships `ctxauth.PrincipalKey` and `ctxobs.LoggerKey`/`RequestIDKey`. -Socrate-specific plan/role defaults move to the Socrate adapter. - -**Migration.** v1.x: implement the existing `With*/Get*` on top of the generic -primitive (no API change). v2.0: keep the common getters as convenience wrappers; -move plan/role business defaults out of core. - -**Backward compatibility.** Source-preserving in v1.x; the deprecated-alias model -already in `context.go:235-247` is the template. - -**Breaking changes.** v2 relocates the "freemium" default and Socrate keys to the -adapter. - -**Risk.** Low. Generic context keys are a well-trodden pattern. - -**Effort.** S-M (≈1 week). - -**Expected benefits.** Extensible without editing core; type-safe; no business -defaults in a generic library; ~60% less boilerplate. - ---- - -## RFC-006 — Middleware composition - -**Motivation.** The framework relies on chi's `r.Use` for ordering and documents a -required order in prose (`README.md:177-179`) — an unenforced convention. -Mis-ordering (e.g. Recover after auth) is a silent footgun. There is no -first-class, router-agnostic composition primitive. - -**Current design.** Each middleware is `func(http.Handler) http.Handler` -(`README.md:342`); composition is the consumer's `r.Use(...)` calls. - -**Proposed design.** A tiny `Chain` with an opinionated, validated default stack: - -```go -type Chain []func(http.Handler) http.Handler -func (c Chain) Then(http.Handler) http.Handler -func (c Chain) Append(...) Chain -func RecommendedStack(opts StackOptions) Chain // RequestID→…→auth→authz, ordered correctly -``` - -`RecommendedStack` encodes the security-correct order once, so consumers get -defense-in-depth ordering for free and can still drop down to raw functions. - -**Migration.** Purely additive; existing `r.Use` usage unaffected. - -**Backward compatibility.** Full. - -**Breaking changes.** None. - -**Risk.** Very low. - -**Effort.** S (≈3–5 days). - -**Expected benefits.** Ordering becomes code, not docs; one-line correct stack; -still router-agnostic (works with chi, stdlib `http.ServeMux`, Gin via shim). - ---- - -## RFC-007 — Configuration model - -**Motivation.** Constructors are inconsistent: positional -(`jwtauth.New(jwksURL, issuer, logger)` `middleware.go:92`, -`tiering.NewGate(registry, logger, upgradeURL)` `gate.go:34`) vs. config structs -(`socrate.ClientConfig` `client.go:71`, `aigateway.Config` `client.go:34`). None -fail-fast on insecure/invalid config (e.g. empty issuer is silently accepted). - -**Current design.** Mixed positional/struct constructors, no `Validate()`. - -**Proposed design.** Standardise on **functional options + a `Validate()` that -refuses insecure boot**: - -```go -m, err := auth.New(verifier, - auth.WithAudience(clientID), // required by default in v2 - auth.WithIssuer(iss), - auth.WithLogger(slogger), -) // returns error; validates -``` - -Every constructor returns `(_, error)` and validates. Provide `MustNew` for tests. - -**Migration.** v1.x: add option-based constructors next to existing ones; -`Validate()` warns. v2.0: positional constructors removed; `Validate()` errors. - -**Backward compatibility.** Preserved through v1.x. - -**Breaking changes.** Constructor signatures at v2; `New` returns an error where it -previously could not. - -**Risk.** Low-medium; the value is partly in *failing closed at startup*, which is -intentional friction. - -**Effort.** M (≈1.5 weeks). - -**Expected benefits.** One idiom; safe-by-default boot; forward-compatible option -addition without signature churn. - ---- - -## RFC-008 — Generics - -**Motivation.** The codebase predates/avoids generics; several APIs hand back -`any`/untyped shapes. Go 1.25 generics enable type-safe pagination, response -envelopes, and handlers with no runtime cost. - -**Current design.** `pagination` returns concrete page math; `apierror.Details` is -`any` (`errors.go:17`); handlers decode/encode by hand. - -**Proposed design.** -- `pagination.Page[T any]` — typed page envelope. -- `apierror.Problem[T any]` — typed `details`. -- `web.JSON[Req, Res any](fn func(ctx, Req) (Res, error)) http.HandlerFunc` — - decode→validate→call→encode, routing errors through `apierror` consistently. -- `ctxutil` generic keys (RFC-005). - -**Migration.** Additive new generic APIs; legacy helpers retained and deprecated. - -**Backward compatibility.** Full (new symbols). - -**Breaking changes.** None. - -**Risk.** Low. Avoid over-generifying (no generic "service" base classes). - -**Effort.** M (≈2 weeks). - -**Expected benefits.** Less per-handler boilerplate; compile-time safety on -request/response shapes; consistent error envelope. - ---- - -## RFC-009 — Event system - -**Motivation.** There is no hook for security/audit events — auth success/failure, -authz denial, rate-limit trip, panic. The security pass flagged the absence of a -native audit writer (INV-14). An in-process event bus is the clean seam that feeds -audit sinks, metrics, and alerting without coupling middleware to any of them. - -**Current design.** Each middleware logs directly via logrus -(`rbac.go:46`, `ratelimit.go` 429 path, `recover.go:19`); no structured event, -no subscription. - -**Proposed design.** A typed, synchronous-by-default event hub: - -```go -type Event struct { Type EventType; Principal Principal; At time.Time; Attrs map[string]any } -type Observer interface{ Observe(context.Context, Event) } -type Bus struct{ ... } // Subscribe(EventType, Observer); thread-safe; non-blocking option -``` - -Middlewares emit `AuthFailed`, `AuthzDenied`, `RateLimited`, `PanicRecovered`. -Ship observers: `slogObserver`, `otelObserver` (RFC-010), `AuditSink` adapter -(RFC-011). - -**Migration.** Additive; default bus is a no-op so existing behaviour is unchanged -until a consumer subscribes. - -**Backward compatibility.** Full. - -**Breaking changes.** None. - -**Risk.** Medium — must not become a hot-path bottleneck; default synchronous with -an explicit async/bounded-buffer mode, and observers must be panic-isolated. - -**Effort.** M (≈2 weeks). - -**Expected benefits.** First-class audit/alerting seam; decouples policy from -sink; enables SOC2/ISO audit-trail story without baking a writer into core. - ---- - -## RFC-010 — Observability (slog + OpenTelemetry) - -**Motivation.** Today: logrus lines only. No tracing, no metrics. `RequestID` -generates a UUID unrelated to W3C trace context (`requestid.go:19-24`). Enterprise -adopters expect OTel traces/metrics out of the box. - -**Current design.** `httpware.Logger` emits one completion line -(`logger.go:46-49`); `RequestID` is a bespoke correlation id. - -**Proposed design.** `backendkit/otel` module: middleware that starts a server -span, propagates W3C `traceparent`, makes `RequestID` derive from / fall back to -the trace ID, and records RED metrics (rate/errors/duration) plus auth/authz -counters fed by the RFC-009 bus. Logging via slog with trace/span IDs auto-injected. - -**Migration.** Additive opt-in module; core stays dependency-light (OTel lives only -in the `otel` module, consistent with RFC-002). - -**Backward compatibility.** Full. - -**Breaking changes.** None (RequestID semantics enrich, not change). - -**Risk.** Low-medium; OTel API churn is mitigated by isolating it in one module. - -**Effort.** M-L (≈2–3 weeks). - -**Expected benefits.** Drop-in distributed tracing and metrics; correlation ties -logs↔traces↔events; competitive parity with Kratos/go-kit observability. - ---- - -## RFC-011 — Pluggable stores (extension points) - -**Motivation.** Stateful pieces are hardcoded to in-process maps: the rate limiter -is an in-memory `map[uuid.UUID]*tenantLimiter` (`ratelimit.go:43`) — wrong for -multi-instance (security pass F-4); the JWKS cache is an in-struct map -(`middleware.go:86`); there is no audit sink. These need interfaces with the -in-memory version as the default adapter. - -**Current design.** Concrete in-memory state inside each middleware; `tiering` -already shows the right pattern with `PolicyRepository` (`policy.go:63`). - -**Proposed design.** Define and depend on interfaces: - -```go -type RateStore interface { Allow(ctx, key string, limit Rate) (bool, error) } -type KeySetCache interface { Get(kid string) (crypto.PublicKey, bool); Put(...) } -type AuditSink interface { Write(ctx, Event) error } -``` - -Ship `memory.RateStore` (today's behaviour) and `redis.RateStore` (new module). -Rate limiter keys on a pluggable `KeyFunc` (tenant→subject→IP fallback), fixing -the nil-tenant bypass at the architecture level. - -**Migration.** v1.x: add interfaces, default to current in-memory impls. v2.0: -constructors take a store option. - -**Backward compatibility.** Preserved via default in-memory adapters. - -**Breaking changes.** Constructor options at v2. - -**Risk.** Low-medium; interface must cover atomic check-and-decrement for -distributed correctness. - -**Effort.** M (≈2 weeks core + per-backend adapters). - -**Expected benefits.** Horizontal-scale-correct rate limiting; swappable JWKS -cache; pluggable durable audit — the operational gaps closed by extension, not -fork. - ---- - -## RFC-012 — Testing infrastructure - -**Motivation.** Tests exist but are thin on the security-critical negative paths -(`jwtauth/middleware_test.go` covers valid/missing/tampered only) and there is no -public test-support surface for *consumers* to test their wiring. - -**Current design.** Per-package `_test.go`; private test helpers (e.g. -`aigateway.ClientForTest` `client.go:254`). - -**Proposed design.** -- `backendkit/.../authtest`: token mint/sign helpers, fake JWKS server, a fake - `Principal`, and context-injection helpers so apps can unit-test handlers - without a live IdP. -- A **conformance suite**: a table-driven `VerifierConformance(t, v)` / - `AuthorizerConformance(t, a)` any adapter must pass (alg-pinning, aud, expiry, - fail-closed) — locks the invariants from the architecture pass. -- Golden-file helpers for `apierror` envelopes. - -**Migration.** Additive new packages. - -**Backward compatibility.** Full. - -**Breaking changes.** None. - -**Risk.** Very low. - -**Effort.** M (≈1.5–2 weeks). - -**Expected benefits.** Third-party adapters provably correct; consumer apps get -turnkey test doubles; the security invariants become executable and -non-regressing. - ---- - -## RFC-013 — SemVer, deprecation & release governance - -**Motivation.** The repo already practices deprecation discipline -(`ctxutil.go:235-247`) but has no written policy, and a multi-module v2 (RFC-002) -needs explicit governance. - -**Current design.** Ad-hoc tags; README references v1.7.0; deprecation by doc -comment only. - -**Proposed design.** -- Written **compatibility policy**: exported API stable within a major; `// - Deprecated:` required one minor before removal; security-default flips only at - majors and pre-announced. -- **Per-module SemVer** with synchronized majors (`core/v2`, `auth/v2`, …) and a - release matrix in CI. -- Tooling: `deprecated`/`staticcheck` lint gate (already have golangci - `.golangci.yml`), `apidiff` in CI to flag accidental breaks, a `CHANGELOG.md` - and `MIGRATION.md` per major. -- Support window: latest major fully supported, previous major security-only for - 12 months. - -**Migration.** Process change; no code impact. - -**Backward compatibility.** This RFC *is* the backward-compatibility guarantee. - -**Breaking changes.** None. - -**Risk.** Low; cost is ongoing discipline. - -**Effort.** S ongoing. - -**Expected benefits.** Predictable upgrades; `apidiff` prevents silent breaks; -clear support contract for enterprise adopters. - ---- - -## Five-Year Sequencing - -| Horizon | Theme | RFCs | -|---------|-------|------| -| **v1.x (0–6 mo)** | Seams in, defaults unchanged | 001, 003, 004, 005, 006, 007, 011 (all additive: interfaces + adapters land, old APIs deprecated) | -| **v2.0 (6–12 mo)** | Decompose + flip safe defaults | 002 (modules), remove logrus, audience/issuer/store required, governance 013 | -| **v2.x (1–2 yr)** | Capabilities | 008 (generics), 009 (events), 010 (OTel), 012 (conformance) | -| **v3 (3–5 yr)** | Ecosystem | adapter zoo (Auth0/Cognito/Keycloak verifiers, Redis/Dynamo stores, OTel-native), optional transport beyond `net/http` (gRPC interceptors mirroring the HTTP middleware) | - ---- - -## Final Answer - -> **If backendkit were to compete with Gin/Echo middleware, go-kit, or Kratos, -> what architectural changes would make it a first-class reusable Go framework?** - -backendkit is currently a *very good internal toolkit* and a *not-yet* general -framework, for one root reason visible throughout the code: **it hardcodes its two -most replaceable dependencies — the identity provider (Socrate) and the logger -(logrus) — into its public API.** `SocrateClaims` lives in the auth middleware -(`middleware.go:45-60`); `*logrus.Entry`/`*logrus.Logger` are in nearly every -constructor (`middleware.go:92`, `logger.go:31`, `recover.go:14`, `gate.go:34`, -`aigateway client.go:66`). No competitor could adopt it without adopting Socrate -and logrus. That, not any single feature gap, is what disqualifies it today. - -The changes required, in priority order: - -1. **Invert the IdP and logger dependencies (RFC-001, 003, 005).** Replace - `*logrus.*` with `*slog.Logger`, and replace `SocrateClaims`-in-core with - `TokenVerifier`/`ClaimsMapper`/`Principal` interfaces, with Socrate demoted to - an *adapter*. This is the single highest-leverage change and the price of entry - to "framework." -2. **Decompose the module (RFC-002).** Gin/Echo win partly because importing them - is cheap; a framework whose `apierror` package transitively pulls gorm and jwt - cannot compete. Core must be std-lib-plus-uuid; integrations pay their own way. -3. **Make authorization composable and object-level-capable (RFC-004).** go-kit - and Kratos expose authz as pluggable; a flat role map is not enough. An - `Authorizer` interface with `All`/`Any` combinators and an ownership seam is - table stakes. -4. **Pluggable stateful stores (RFC-011).** In-memory rate limiting - (`ratelimit.go:43`) is fine for one binary; a framework must offer a - `RateStore` with a Redis adapter, or it is unusable at scale. -5. **First-class observability (RFC-010) and an event/audit seam (RFC-009).** - Kratos/go-kit ship OTel and middleware hooks; parity requires traces, RED - metrics, and an event bus that audit sinks subscribe to. -6. **A configuration idiom and a conformance test kit (RFC-007, 012).** Functional - options with fail-closed `Validate()`, plus a published conformance suite so - third-party adapters are provably correct — this is what turns a library into - an *ecosystem*. - -What it does **not** need to change — and should protect — is its genuine -differentiator: it is **opinionated about security defaults** in a way Gin/Echo -deliberately are not. Gin gives you a router and leaves auth/z, tenancy, and error -shaping to you; backendkit already ships fail-closed auth, a tiering model, typed -errors, and a security-correct middleware order. If the v2.0 program lands the six -changes above **while keeping those opinionated, secure defaults**, backendkit -occupies a real, defensible niche the incumbents leave open: **"the batteries- -included, secure-by-default backend framework for multi-tenant SaaS"** — Kratos's -structure with Rails-like security ergonomics. - -**Verdict:** the distance to first-class is an *abstraction and packaging* -program, not a rewrite. Every concrete behaviour worth keeping already exists; v2.0 -is about putting interfaces where Socrate and logrus are wired in, and splitting -the module so adopters pay only for what they use. Execute RFC-001/002/003 and -backendkit graduates from internal toolkit to a framework I would put on the same -shortlist as Kratos. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 258c6b4..60e01db 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://github.com/ovander/backendkit/actions/workflows/ci.yml/badge.svg)](https://github.com/ovander/backendkit/actions/workflows/ci.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/ovander/backendkit)](https://goreportcard.com/report/github.com/ovander/backendkit) -Shared Go library for backend services that use [Socrate](https://github.com/ovander/socrate) as their OAuth2/OIDC provider. +Shared Go library for backend services that use [Socrate](https://github.com/ovander/go-oauth2) as their OAuth2/OIDC provider. --- @@ -19,12 +19,14 @@ Shared Go library for backend services that use [Socrate](https://github.com/ova - [Architecture overview](#architecture-overview) - [Full integration example](#full-integration-example) - [Package reference](#package-reference) - — [apierror](#apierror) · [ctxutil](#ctxutil) · [httpware](#httpware) · [gormlogger](#gormlogger) · [socrate](#socrate) · [jwtauth](#jwtauth) · [tiering](#tiering) · [pep](#pep) · [aigateway](#aigateway) · [ainarration](#ainarration) · [pagination](#pagination) · [buildinfo](#buildinfo) + — [apierror](#apierror) · [ctxutil](#ctxutil) · [httpware](#httpware) · [gormlogger](#gormlogger) · [socrate](#socrate) · [jwtauth](#jwtauth) · [bff](#bff) · [pep](#pep) · [tiering](#tiering) · [aigateway](#aigateway) · [ailang](#ailang) · [ainarration](#ainarration) · [pagination](#pagination) · [buildinfo](#buildinfo) - [Testing](#testing) - [Troubleshooting](#troubleshooting) - [Production usage](#production-usage) - [Versioning](#versioning) - [Contributing](#contributing) +- [Security](#security) +- [License](#license) --- @@ -141,10 +143,12 @@ func main() { | [`httpware`](#httpware) | Chi-compatible middlewares: RequestID, Logger, SecurityHeaders, BodyLimit, Recover, Timeout, RateLimiter, RequireTenant, RBAC, Metrics (Prometheus RED) | | [`gormlogger`](#gormlogger) | GORM → logrus bridge with slow-query detection | | [`jwtauth`](#jwtauth) | JWT RS256 validation middleware with JWKS cache and stale-key fallback | +| [`bff`](#bff) | Backend-for-Frontend runtime: server-side sessions, `__Host-` cookies, CSRF, PKCE, login binding and a fail-closed session→bearer proxy with coalesced token refresh | | [`socrate`](#socrate) | Full Socrate API client: user CRUD, service-account token, magic links, invite, app & superadmin management, security monitoring, dashboard, audit logs, token introspection/revocation | | [`tiering`](#tiering) | Plan registry, tier gate middleware, feature policy model and service | | [`pep`](#pep) | Policy enforcement point: asks Socrate's policy decision point about each action and honours the central `POLICY_MODE` (off / shadow / enforce) and obligations | | [`aigateway`](#aigateway) | Multi-provider AI client (OpenAI + Claude), `ExtractJSON`/`ExtractJSONInto` | +| [`ailang`](#ailang) | Language guard for AI output: every response is in the requested locale (fr/en), with retry and translation fallback | | [`ainarration`](#ainarration) | Generic LRU+TTL narration cache and `CacheKey` helper | | [`pagination`](#pagination) | Query-param parsing and `PagedResponse` | | [`buildinfo`](#buildinfo) | Build-time version metadata (`-ldflags`) and a `/version` HTTP handler | @@ -156,6 +160,7 @@ Packages are independent — `go get` pulls the whole module, but importing one | I want to… | Use | |------------|-----| | Validate incoming Socrate JWTs and populate the request context | [`jwtauth`](#jwtauth) | +| Serve a browser SPA without ever giving it OAuth tokens (BFF) | [`bff`](#bff) + [`socrate`](#socrate) | | Read the tenant / user / role / plan of the current request | [`ctxutil`](#ctxutil) | | Add request IDs, structured logging, panic recovery, timeouts, body limits, security headers | [`httpware`](#httpware) | | Rate-limit per tenant | [`httpware.RateLimiter`](#httpware) | @@ -167,6 +172,7 @@ Packages are independent — `go get` pulls the whole module, but importing one | Return consistent JSON errors | [`apierror`](#apierror) | | Call Socrate to manage users, apps, tokens, or security | [`socrate`](#socrate) | | Call OpenAI or Claude through one interface | [`aigateway`](#aigateway) | +| Guarantee AI output is in the user's language | [`ailang`](#ailang) | | Cache AI results to cut latency and cost | [`ainarration`](#ainarration) | | Parse `?page`/`?per_page` and return paged lists | [`pagination`](#pagination) | | Log GORM queries through logrus / flag slow queries | [`gormlogger`](#gormlogger) | @@ -567,6 +573,91 @@ check needs; `pep` uses them to honour policy obligations. --- +### bff + +The runtime of a Backend-for-Frontend. In a BFF the browser never holds OAuth tokens: the BFF is +the confidential client, runs Authorization Code + PKCE server-side, keeps the tokens in a +server-side session and gives the browser only an opaque `HttpOnly` cookie. The token calls +themselves come from the [`socrate`](#socrate) package — `*socrate.Client` is the gateway's +refresher. The Socrate admin and monitoring consoles run on this package. + +```go +client, err := socrate.NewClient(socrate.ClientConfig{ + BaseURL: "https://socrate.example.com", ClientID: "my-bff", ClientSecret: "…", +}) +if err != nil { + log.Fatal(err) +} + +store := bff.NewMemoryStore(30*time.Minute, 8*time.Hour) // idle, absolute; call store.Sweep() on a ticker +gw := &bff.Gateway{ + Store: store, + Cookie: bff.CookieConfig{Name: "app_session", Secure: true}, // sent as __Host-app_session + Refresher: client, // *socrate.Client refreshes the tokens +} +login := bff.LoginBinding{Cookie: bff.CookieConfig{Name: "app_login", Secure: true}} + +// The browser starts here; it never sees a token. +mux.HandleFunc("/bff/login", func(w http.ResponseWriter, r *http.Request) { + p, state := bff.NewPKCE(), bff.RandomToken(32) + pending[state] = pendingLogin{ + Verifier: p.Verifier, + Nonce: login.Begin(w), // ties the callback to this browser + ReturnTo: bff.SanitizeReturnTo(r.URL.Query().Get("return_to")), + } + q := url.Values{ + "response_type": {"code"}, "client_id": {"my-bff"}, "redirect_uri": {redirectURI}, + "scope": {"openid profile email"}, "state": {state}, + "code_challenge": {p.Challenge}, "code_challenge_method": {"S256"}, + } + http.Redirect(w, r, "https://socrate.example.com/oauth/authorize?"+q.Encode(), http.StatusFound) +}) + +mux.HandleFunc("/bff/callback", func(w http.ResponseWriter, r *http.Request) { + st, ok := pending[r.URL.Query().Get("state")] + delete(pending, r.URL.Query().Get("state")) + if !ok || !login.Verify(w, r, st.Nonce) { + http.Error(w, "invalid login", http.StatusBadRequest) + return + } + ts, err := client.ExchangeCode(r.Context(), r.URL.Query().Get("code"), redirectURI, st.Verifier) + if err != nil { + http.Error(w, "login failed", http.StatusBadGateway) + return + } + s := bff.NewSession(bff.RandomToken(32), bff.RandomToken(32), ts, bff.UserInfo{ /* from the token claims */ }, time.Now()) + store.Put(s) + gw.Cookie.SetSession(w, s.ID()) + http.Redirect(w, r, st.ReturnTo, http.StatusFound) +}) + +// Every API call: session cookie in, bearer out. No valid session ⇒ 401, never a pass-through. +// Unsafe methods must carry the session's CSRF token in X-CSRF-Token. +mux.HandleFunc("/api/", gw.ProxyWithSession(bff.NewSingleHostProxy(apiURL))) +``` + +`pending` stands for any server-side store of `{Verifier, Nonce, ReturnTo}` keyed by `state`, with +a short expiry. [`oauth2-admin/bff`](https://github.com/ovander/oauth2-admin/tree/main/bff) is a +complete production BFF built this way. + +**Safe by default.** + +| Concern | Behaviour | +|---|---| +| No or expired session | `ProxyWithSession` answers **401**; it never forwards the request (opt-out: `AllowPassthrough`) | +| CSRF | Unsafe methods need the session's token in `X-CSRF-Token` (constant-time compare), else **403** | +| Cookie | `HttpOnly`, and `__Host-` prefixed when `Secure` | +| Login CSRF / session swap | `LoginBinding` accepts a callback only from the browser that started the login | +| Open redirect | `SanitizeReturnTo` keeps only same-site paths such as `/dashboard?x=1`; absolute URLs, `//host`, backslash and control-character tricks all become `/` | +| Token refresh | Proactive, coalesced per session, detached from the triggering request, and written through to the store so the rotated refresh token is kept. Only a refresh the server rejects (`IsFatalRefreshError`) ends the session; a transient failure answers 502 and keeps it | +| Upstream attribution | `NewSingleHostProxy` strips client-supplied `X-Forwarded-For` and similar headers, so the browser cannot steer Socrate's rate limits, IP blocks or audit trail | + +**Several instances.** `MemoryStore` is per process. Behind a load balancer, implement +`SessionStore` (`Get`, `Put`, `Delete`, `Sweep`) over a shared database, serialising sessions +with `Session.Snapshot` / `NewSessionFromSnapshot`. + +--- + ### pep The policy enforcement point for Socrate's policy decision point (Socrate A4). @@ -718,6 +809,28 @@ For tests, `aigateway.ClientForTest(provider, apiKey, serverURL)` points both pr --- +### ailang + +A language guard for AI output: every `AIResponse.Text` is in the requested locale (`fr` or +`en`). It prepends a language directive to the prompt, checks the answer with a fast stopword +heuristic, retries once with a reinforced prompt, and as a last resort translates the answer with +the same model. + +```go +guard := ailang.New(aiClient, ailang.DefaultAIConfig(), nil, logger) // aiClient: *aigateway.Client; nil reporter = no-op + +resp, err := guard.Generate(ctx, ailang.PromptInput{ + Prompt: "Explique les résultats du plan.", + Locale: "fr", + Metadata: map[string]any{"module": "insight"}, +}) +``` + +Mismatches, retries and translation fallbacks are reported through the optional `EventReporter` +(for example a Sentry adapter), so language drift is observable rather than silent. + +--- + ### ainarration A generic LRU+TTL cache for AI narration results, keyed by tenant and a content-addressed `CacheKey`. `NarrationCacher` is an interface — implement it with a DB-backed layer for persistence across restarts. @@ -869,4 +982,21 @@ go vet ./... # static analysis 3. Add runnable examples in `example_test.go` — they appear on pkg.go.dev. 4. All exported symbols must have Go doc comments that begin with the symbol name. 5. Run `go test -race ./...` and `go vet ./...` before opening a PR. -6. Keep packages decoupled — the only allowed cross-package imports within the library are `ctxutil` and `apierror` (shared primitives). All other cross-package imports are prohibited. +6. Keep packages loosely coupled — every package may use the shared `ctxutil` and `apierror` primitives; beyond those, the only intra-module imports are `bff` → `socrate`, `pep` → `socrate` and `aigateway` → `ailang`. Discuss any new one first. + +The full workflow — required checks, commit style, changelog and pull-request template — is in +[CONTRIBUTING.md](CONTRIBUTING.md). + +--- + +## Security + +Please report vulnerabilities privately through the repository's **Security** tab → **Report a +vulnerability**, not in a public issue. Scope and supported versions are in +[SECURITY.md](SECURITY.md). + +--- + +## License + +backendkit is licensed under the [Apache License 2.0](LICENSE). diff --git a/SECURITY-ARCHITECTURE.md b/SECURITY-ARCHITECTURE.md deleted file mode 100644 index 20315d9..0000000 --- a/SECURITY-ARCHITECTURE.md +++ /dev/null @@ -1,572 +0,0 @@ -# Fourth Pass — Security Architecture Verification - -**Subject:** `github.com/ovander/backendkit` (Go 1.25, commit `46105b1`) -**Lens:** Security Architect. The vulnerability hunt is assumed complete -(see `SECURITY-AUDIT.md`). This document **verifies whether the framework -enforces the correct security invariants** and maps the already-known findings -onto architecture, threat, compliance, and evolution. -**Rule of engagement:** no new coding defects are raised unless they violate a -declared architectural security invariant. Every verdict cites `file:line`. - ---- - -## 1. Threat Model - -### 1.1 Assets -| Asset | Where it lives in backendkit | -|-------|------------------------------| -| User identity & claims | `jwtauth` → `ctxutil` context values | -| Tenant boundary | `ctxutil` tenant ID, `httpware/ratelimit`, `tiering` | -| Bearer JWT (raw) | `ctxutil.WithRawJWT` (`ctxutil.go:191`), forwarded by `socrate` | -| Service-account secret | `socrate.Client.clientSecret` (`client.go:59`) | -| AI provider API keys | `aigateway.Client.apiKey` (`client.go:53`) | -| Entitlement / plan state | `tiering.PolicyService` (DB-backed cache) | - -### 1.2 Actors / trust levels -- **T0 Anonymous internet** — controls request bytes, headers, body, and the - bearer token *string*. -- **T1 Authenticated user** — holds a Socrate-issued JWT for *some* app. -- **T2 Tenant admin / elevated role** — higher RBAC role / plan. -- **T3 Service account (M2M)** — `client_credentials` holder. -- **T4 The service binary itself** — fully trusted; shares one `context.Context`. -- **TX Socrate IdP + JWKS endpoint** — external trust anchor. -- **TY External AI providers** — egress dependency. - -### 1.3 STRIDE summary (mapped to verified invariants in §6) -| STRIDE | Primary exposure | Invariant | Status | -|--------|------------------|-----------|--------| -| **S**poofing | Cross-app token reuse; tenant absence | INV-2, INV-6 | **Violated** (F-1, F-3) | -| **T**ampering | Token signature; context injection | INV-4, INV-5 | **Enforced** | -| **R**epudiation | No native audit writer | INV-14 | **Not provided** | -| **I**nfo disclosure | Error body; SQL logs | INV-9, INV-10 | **Partial** (F-17, F-9) | -| **D**enial of service | Rate-limit bypass; unbounded reads | INV-8, INV-12 | **Violated/Partial** (F-4, F-8) | -| **E**levation | Stale token after revoke; opt-in authz | INV-3, INV-7 | **Violated/Partial** (F-2) | - ---- - -## 2. Trust Boundary Diagram - -``` - T0/T1 Internet (untrusted) - │ HTTP request + "Authorization: Bearer " - ▼ - ┌─────────────────────────────────────────────────────────────┐ - │ TLS-terminating proxy (NOT in backendkit — operator owned) │ - └─────────────────────────────────────────────────────────────┘ - │ - ╔════════════════▼══════════════════════════════════════════════╗ TRUST - ║ Service binary (T4) ║ BOUNDARY #1 - ║ ║ (token→identity) - ║ RequestID → Logger → SecurityHeaders → BodyLimit → ║ - ║ Recover → Timeout → [ jwtauth.Handler ] ──── verifies sig, ║ - ║ alg, exp, (iss?) against JWKS ║ - ║ │ writes ctxutil identity ║ - ║ ▼ ║ - ║ RateLimiter → RBAC.Require → tiering.Gate → Handler ║ - ║ │ ║ - ║ ┌──────────────────────────┼──────────────────────────────┐ ║ - ║ │ ctxutil (in-process identity store, unexported keys) │ ║ - ║ └──────────────────────────┼──────────────────────────────┘ ║ - ╚══════════════════════════════┼════════════════════════════════╝ - │ socrate.Client (Bearer fwd / M2M) │ aigateway (api key) - ▼ TRUST BOUNDARY #2 ▼ TRUST BOUNDARY #3 - ┌──────────────────────────┐ ┌──────────────────────────────┐ - │ TX Socrate IdP │ │ TY OpenAI / Anthropic │ - │ :8080 OAuth :8081 Admin│ │ (egress, holds API key) │ - │ JWKS endpoint (anchor) │ └──────────────────────────────┘ - └──────────────────────────┘ -``` - -**Boundary #1** is the one backendkit owns and is judged on. Boundaries #2/#3 are -*delegated* trust (Socrate, AI providers). The **root of all identity trust is the -JWKS endpoint at TX** — a single anchor with no key-pinning or min-strength check -(F-10 / INV-13). - ---- - -## 3. Authentication Flow Diagram - -``` - client ──Bearer──▶ jwtauth.Handler (middleware.go:104) - │ - ├─ extractBearer (middleware.go:179) ──fail──▶ 401 (fail-closed) ✔INV-1 - │ - ├─ ParseWithClaims (middleware.go:198) - │ ├─ WithValidMethods{"RS256"} (193) ─── alg pinned ✔INV-4 - │ ├─ keyfunc: assert *SigningMethodRSA (199) ─ blocks alg=none ✔INV-4 - │ ├─ require kid header (202) - │ ├─ getKey(kid) (215) ──▶ JWKS cache (TTL 1h) / fetch / STALE fallback (225) - │ ├─ WithIssuer ONLY IF iss!="" (194) ─── ✗ optional ▲F-5/INV-2partial - │ └─ exp / nbf (golang-jwt default) ─── ✔ INV-3(exp) - │ ✗ NO WithAudience anywhere ─── ▲F-1/INV-2 VIOLATED - │ ✗ token_version captured, never checked (163) ─ ▲F-2/INV-3 VIOLATED - │ - ├─ on any failure ─────────▶ 401 (115) ✔ fail-closed - │ - └─ success: write identity to ctx (120-169) - tenant(if present) user role plan app_roles raw_jwt - keys are unexported type ─── client cannot forge ✔INV-5 -``` - -Verified properties: **fail-closed** (`middleware.go:106-118`), **algorithm -pinning at two layers** (`193`, `199-201`), **identity un-forgeable via headers** -(`ctxutil.go:20-39`). Missing properties: **audience binding** and **revocation**. - ---- - -## 4. Authorization Flow Diagram - -``` - authenticated ctx - │ - ▼ - RateLimiter.Handler (ratelimit.go:106) - │ tenantID := GetTenantID(ctx) - ├─ tenantID == uuid.Nil ─────────────▶ PASS THROUGH (108-112) ▲F-4/INV-8 VIOLATED - └─ else getLimiter(tenant).Allow() ── 429 on exceed - │ - ▼ - RBAC.Require(perm) (rbac.go:41) ── OPT-IN per route ▲INV-7 PARTIAL - │ role := GetUserRole(ctx) ── global role, NOT app_roles ▲role-confusion - ├─ role∉map OR perm∉role ─────────────▶ 403 (49) ✔ fail-closed - └─ allow - │ - ▼ - tiering.Gate.Require(minPlan) (gate.go:46) ── OPT-IN per route - │ plan := Normalise(GetUserPlan(ctx)) ── unknown→lowest tier ✔ fail-closed (plan.go:78) - ├─ !TierAtLeast ─────────────────────▶ 403 upgrade_required (58) - └─ allow - │ - ▼ - Handler ── object-level / ownership / tenant-row filter: NOT PROVIDED ▲INV-6/INV-7 (app-owned) -``` - -Verified: every gate that *is mounted* fails closed. **Not verified / not -enforceable by the framework:** that a gate is mounted at all (opt-in), and that -the handler performs object-level and tenant-row authorization. - ---- - -## 5. Defense-in-Depth Analysis - -| Layer | Control present | Evidence | Depth verdict | -|-------|-----------------|----------|---------------| -| Network | (delegated) TLS/segmentation | `client.go:19-21` notes :8081 "restrict at network level" | Out of scope | -| Request hygiene | BodyLimit, RequestID | `bodylimit.go:14`, `requestid.go:17` | ✔ thin but real | -| Crash containment | Recover (no stack leak) | `recover.go:18-23` | ✔ solid | -| Transport headers | SecurityHeaders (CSP/HSTS/nosniff/frame-deny) | `security.go:17-29` | ✔ strong | -| **AuthN** | RS256 verify, alg pin, fail-closed | `middleware.go:193,199-201,106-118` | ◑ no aud/revocation | -| **AuthZ** | RBAC + plan Gate, fail-closed | `rbac.go:49`, `gate.go:58` | ◑ opt-in, no object-level | -| Tenant isolation | tenant from claim | `middleware.go:122-131` | ✗ fail-open default | -| Abuse control | per-tenant rate limit | `ratelimit.go:106` | ✗ nil-tenant bypass, in-memory | -| Output safety | apierror shaping | `errors.go:186-190` | ◑ Message leaks to client | -| Observability | structured logs, correlation | `logger.go:34-49` | ◑ SQL value logging | - -**Architectural read:** the **outer** layers (hygiene, crash, headers) are -complete and correct. The **core** layers (authN/authZ/tenant/abuse) are each -*individually* fail-closed *when invoked* but have **no redundancy** — there is -exactly one check per concern and several of those single checks are either -optional (RBAC/Gate/issuer), bypassable (rate limit on nil tenant), or absent -(audience, revocation, object-level). Defense-in-depth is **shallow at the core**: -a single missing `r.Use(...)` or a default-config token removes the only barrier. - ---- - -## 6. Security Invariants — Verification Matrix - -The spine of this pass. Each invariant is what an architect *expects* the -framework to guarantee, with VERIFIED / VIOLATED / DELEGATED status and evidence. - -> **Updated post v1.8.0 (2026-06-20).** Status reflects the shipped remediation -> (#7, #15, #17, #19, #11, #13). The legend `✱ (opt-in)` marks an invariant the -> framework now **provides a control for** but does **not** enforce by default — -> verified once the app configures it; a by-default guarantee is the v2.0 -> default-flip. The original (pre-remediation) statuses are shown struck for -> traceability. - -| ID | Invariant | Status (v1.8.0) | Evidence / Finding | -|----|-----------|-----------------|--------------------| -| INV-1 | No protected handler runs without a verified identity | **VERIFIED** (where mounted) | fail-closed 401 `middleware.go:106-118` | -| INV-2 | A token is accepted only by the audience it was issued for | ~~VIOLATED~~ → **VERIFIED ✱ (opt-in)** | `WithAudience` shipped #7 (F-1); default-off | -| INV-3 | Expired/revoked credentials are rejected | ~~PARTIAL~~ → **VERIFIED ✱ (opt-in)** | exp ✔ default; `WithRevocationCheck` shipped #17 (F-2) | -| INV-4 | Signature algorithm & key are attacker-uninfluenceable | **VERIFIED** | `193`, `199-201` | -| INV-5 | Context identity derives only from verified claims; client cannot inject | **VERIFIED** | unexported keys `ctxutil.go:20-39`; written only in `middleware.go:120-169` | -| INV-6 | Every tenant-scoped op is bound to a non-nil verified tenant | ~~VIOLATED~~ → **VERIFIED ✱ (opt-in)** | `RequireTenant` shipped #15 (F-3) | -| INV-7 | Authorization is mandatory for protected resources | **PARTIAL** | opt-in middleware `README.md:259-265` | -| INV-8 | Abuse controls cannot be bypassed by omitting identity | ~~VIOLATED~~ → **PARTIAL** | `RequireTenant` (#15) blocks nil-tenant upstream; limiter still in-memory/per-tenant (F-4) | -| INV-9 | Errors/panics never disclose internal state to clients | ~~PARTIAL~~ → **VERIFIED** | Recover ✔; 5xx Message/Details redacted shipped #29 (F-17, v1.9.0) | -| INV-10 | Secrets are never logged or serialized | **VERIFIED** | no secret logging; opt-in `gormlogger.WithSQLRedaction` for SQL values shipped #27 (F-9, v1.9.0) | -| INV-11 | Untrusted input cannot restructure outbound requests | ~~VIOLATED~~ → **VERIFIED** | `url.PathEscape` on socrate userID shipped #23 (F-7, v1.9.0) | -| INV-12 | Resource consumption is bounded | ~~PARTIAL~~ → **VERIFIED** | BodyLimit ✔; upstream reads bounded shipped #25 (F-8, v1.9.0); stdlib CVEs cleared via Go 1.26.4 (#11) | -| INV-13 | Cryptographic keys meet a minimum strength | ~~VIOLATED~~ → **VERIFIED** | min 2048-bit + exponent validation shipped #19 (F-10); **default-on** | -| INV-14 | Security-relevant actions are durably, tamper-evidently recorded | **DELEGATED** | only reads Socrate logs `client.go:835`; no writer | - -**Score (v1.9.0): 8 VERIFIED, 3 VERIFIED✱ (opt-in), 2 PARTIAL, 0 VIOLATED, -1 DELEGATED** — was *4 VERIFIED, 4 PARTIAL, 4 VIOLATED, 2 DELEGATED* at first -review; *5/3/4/1/1* after v1.8.0. **No invariant is VIOLATED.** v1.9.0 moved -INV-9 (5xx error redaction, #29), INV-11 (socrate path-escaping, #23), and INV-12 -(bounded reads, #25) to VERIFIED, and confirmed INV-10 (opt-in SQL redaction, -#27). Only **INV-7** (mandatory authz) and **INV-8** (distributed rate-limit -store) remain PARTIAL, and the three ✱ auth/tenant controls (INV-2/3/6) are -verified **when enabled**. The remaining v2.0 step is flipping the ✱ controls to -default-on so the guarantees hold without per-app configuration. - ---- - -## 7. Attack Trees — Every High/Critical Finding - -### F-1 / INV-2 — Cross-app token reuse (HIGH) -``` -GOAL: Act as a user inside App-B using a token minted for App-A -└─ AND - ├─ Obtain a valid Socrate JWT for App-A - │ ├─ be a legitimate App-A user (T1) [trivial] - │ └─ OR phish/replay any App-A token - └─ Present it to App-B - └─ App-B validates: sig ✔ (same JWKS), iss ✔ (same issuer), - exp ✔, alg ✔ … aud ✗ NOT CHECKED (middleware.go:191-207) - └─ RESULT: token accepted; identity injected; App-B RBAC role - comes from the *same* `role` claim → lateral access -MITIGATION (today): only if App-A and App-B happen to use different issuers - (config-dependent, not guaranteed) -KILL: enforce WithAudience(appClientID) ✅ shipped v1.8.0 (#7, opt-in) -``` - -### F-2 / INV-3 — Use of a logically-revoked token (HIGH) -``` -GOAL: Keep access after logout / password change / admin revoke -└─ AND - ├─ Capture a still-unexpired access token (XSS, log, proxy, device theft) - └─ Backend never checks revocation - ├─ token_version captured but never compared (middleware.go:163-165) - ├─ no introspection on hot path (IntrospectToken exists, unused, client.go:741) - └─ no revocation list / no token binding - └─ RESULT: token valid until exp regardless of server-side revoke -KILL: token_version callback OR introspect-on-sensitive-route ✅ shipped v1.8.0 (#17, WithRevocationCheck) -``` - -### F-3 / INV-6 — Cross-tenant access via absent tenant (HIGH) -``` -GOAL: Read/write across tenant boundaries -└─ OR - ├─ Default-config path (no compensating control) - │ ├─ Socrate default does NOT issue tenant_id (middleware.go:38-40) - │ ├─ GetTenantID → uuid.Nil, no error (ctxutil.go:49-54) - │ └─ downstream query scoped to Nil tenant → shared/global rows - └─ Overwrite path - └─ any in-binary code calls WithTenantID again (exported, ctxutil.go:44) - → no write-once invariant -KILL: RequireTenant middleware (401 on uuid.Nil) + write-once tenant ✅ RequireTenant shipped v1.8.0 (#15); write-once = v2.0 -``` - -### F-4 / INV-8 — Rate-limit / abuse-control bypass (HIGH) -``` -GOAL: Flood the service / brute force without throttling -└─ OR - ├─ Default-config bypass - │ └─ nil-tenant request passes through unlimited (ratelimit.go:108-112) - │ (and default tokens carry no tenant → ALL requests bypass) - ├─ Horizontal scale bypass - │ └─ in-memory limiter per instance (ratelimit.go:43) - │ → effective limit = N_instances × rps - └─ Tenant-internal DoS - └─ one user exhausts the shared per-tenant bucket (getLimiter keyed on tenant only) -KILL: fallback key (subject/IP) + distributed store (Redis) + per-subject buckets ◑ partially mitigated by RequireTenant (#15); limiter changes still open -``` - ---- - -## 8. Compliance Mapping - -| Framework | Control | backendkit posture | Evidence | -|-----------|---------|--------------------|----------| -| **OWASP ASVS 4.0** | V2.1 token verification | ◑ alg/sig ✔, aud ✗ | `middleware.go:193`/F-1 | -| | V3.3 session/token revocation | ✗ | F-2 | -| | V4.1 mandatory access control | ◑ opt-in | `rbac.go`/INV-7 | -| | V4.2 BOLA/object-level | ✗ (app) | Phase 4 | -| | V7.1 log no sensitive data | ◑ SQL values | `gormlogger:88-95`/F-9 | -| | V8.3 no secret in logs | ✔ | `aigateway:91-95` | -| | V14.4/14.5 security headers | ✔ | `security.go:17-29` | -| **OWASP API Top 10 (2023)** | API1 BOLA | ✗ (app-owned) | no object-level authz | -| | API2 Broken Auth | ◑ aud+revocation gaps | F-1, F-2 | -| | API3 BOPLA / mass-assignment | ✔ typed structs | `socrate client.go:309-401` | -| | API4 Unrestricted Resource Consumption | ◑ | F-4, F-8 | -| | API5 BFLA | ◑ opt-in RBAC | INV-7 | -| | API8 Misconfiguration | ◑ empty-issuer default | F-5 | -| **CWE** | CWE-287 improper auth | F-1 | INV-2 | -| | CWE-613 insufficient expiration/revocation | F-2 | INV-3 | -| | CWE-284/639 improper access / IDOR | F-3 | INV-6 | -| | CWE-770 alloc without limit | F-8 | INV-12 | -| | CWE-799 improper interaction frequency | F-4 | INV-8 | -| | CWE-532 info in log | F-9 | INV-10 | -| | CWE-209 error info exposure | F-17 | INV-9 | -| | CWE-88/74 argument/URL injection | F-7 | INV-11 | -| | CWE-326 inadequate key strength | F-10 | INV-13 | -| **NIST 800-53** | IA-2/IA-5 auth & credentials | ◑ | F-1/F-2 | -| | AC-3/AC-4 access & flow enforcement | ◑/✗ | INV-6/INV-7 | -| | AU-2/AU-9 audit & protection | ✗ native | INV-14 | -| | SC-5 DoS protection | ◑ | F-4/F-8 | -| | SC-12/13 crypto & key mgmt | ◑ | F-10 | -| | SI-10 input validation | ◑ | F-7 | -| **SOC 2 (TSC)** | CC6.1 logical access | ◑ | F-1/F-3 | -| | CC6.6 boundary protection | ◑ | INV-6 | -| | CC7.2 monitoring | ◑ correlation ✔, audit ✗ | INV-14 | -| | CC8.1 change mgmt | ✔ deprecation discipline | `ctxutil.go:235-247` | -| **ISO 27001:2022 Annex A** | A.5.15 access control | ◑ | INV-7 | -| | A.5.17 authentication info | ◑ | F-2 | -| | A.8.16 monitoring | ◑ | INV-14 | -| | A.8.24 use of cryptography | ◑ | F-10 | -| | A.8.28 secure coding | ✔ | lint/vet/race CI `ci.yml:34-37` | - -**Net:** clean passes on headers, secret-hygiene, typed I/O, and secure-coding -process; **conditional/fail** on token audience, revocation, mandatory access -control, tenant isolation, native audit, and key-strength — the regulated-industry -blockers. - ---- - -## 9. Security Regression Test Suite (to lock the invariants) - -The current suite covers valid/missing/tampered tokens only -(`jwtauth/middleware_test.go:73-166`). To make the invariants *enforced and -non-regressing*, add: - -``` -jwtauth (INV-2,3,4): - TestRejectsAlgNone // forge {"alg":"none"} → 401 - TestRejectsHS256WithPublicKey // alg-confusion → 401 - TestRejectsWrongAudience // aud=app-A token at app-B → 401 (RED until F-1 fixed) - TestRejectsExpired // exp in past → 401 - TestEnforcesIssuerWhenConfigured // bad iss → 401 - TestRejectsMissingKid // no kid → 401 - TestRejectsStaleTokenVersion // token_version < current → 401 (RED until F-2 fixed) - TestRejectsUndersizedRSAKey // 1024-bit JWKS key → reject (RED until F-10 fixed) - -httpware (INV-5,8,9): - TestContextIdentityNotForgeableViaHeader // X-User-Role header ignored - TestRateLimitDeniesNilTenant // no-tenant request throttled (RED until F-4 fixed) - TestRecoverHidesStackFromClient // body has no stack/panic text - -tenant (INV-6): - TestRequireTenantRejectsNil // uuid.Nil → 401 (RED until F-3 fixed) - -socrate (INV-11): - TestPathParamsAreEscaped // userID="../x" cannot escape path (RED until F-7 fixed) - -apierror (INV-9): - TestInternalMessageNotLeaked // 5xx body omits raw Message (RED until F-17 fixed) -``` - -The "RED until fixed" cases are the **executable specification of the missing -invariants** — they should be committed now as failing/skipped tests so the gaps -cannot silently persist. - ---- - -## 10. Supply-Chain Review - -**Direct dependencies (`go.mod`):** 5 — `golang-jwt/jwt/v5 v5.2.1`, -`google/uuid v1.6.0`, `sirupsen/logrus v1.9.3`, `golang.org/x/time v0.15.0`, -`gorm.io/gorm v1.25.11`. Indirect: `jinzhu/inflection`, `jinzhu/now`, -`golang.org/x/sys v0.28.0`, `golang.org/x/text v0.14.0`. Test-only: -`stretchr/testify`, `objx`, `go-spew`, `go-difflib`, `yaml.v3`. - -**Assessment:** small, reputable, well-maintained surface — architecturally a -strong position (minimal attack surface, no obscure transitive auth/crypto libs). - -**Integrity controls present:** `go.sum` is pinned and **CI verifies it is tidy** -(`ci.yml:25-28`), build/test run with `-race` (`ci.yml:34`), `go vet` and -`golangci-lint v2.5.0` gate PRs (`ci.yml:36-51`). - -**Architectural gap (violates the spirit of INV-12/process):** -- **No Software-Composition-Analysis in CI** — `ci.yml` has **no `govulncheck`, - no `gosec`, no Dependabot/Renovate, no `mcp__github__run_secret_scanning` - wiring**. There is no continuous mechanism to learn that a pinned dependency - became vulnerable. -- **`golang-jwt/jwt/v5 v5.2.1` is a stale auth-critical pin.** This line is the - cryptographic trust root of the whole framework; the v5.2.x line received a - security fix for excessive memory allocation while parsing attacker-controlled - tokens (DoS) in **v5.2.2**. Pinning the verifier one patch below the latest - security release, with no SCA to flag it, is the supply-chain finding that most - directly intersects the auth boundary and INV-12. *Action: bump to the latest - v5.2.x and add `govulncheck` to CI; treat any advisory on the JWT/crypto deps as - release-blocking.* - -(Per rules of engagement, this is raised because it bears on INV-12 / the auth -trust root, not as a generic version-bump nit.) - ---- - -## 11. Cryptographic Architecture Review - -- **Primitive surface is deliberately tiny:** the only cryptography backendkit - *performs* is **RS256 signature verification**, fully delegated to - `golang-jwt/v5` with the algorithm pinned (`middleware.go:193, 199-201`). No - bespoke crypto, no symmetric ciphers, no MAC, no nonce/IV management — the safest - possible posture. ✔ -- **Randomness:** identifiers come from `google/uuid` v4 (`requestid.go:22`, - `middleware.go:138`), `crypto/rand`-backed. The only `math/rand`/`crypto/rand` - import is in a test (`middleware_test.go:4`). ✔ -- **Hashing:** `uuid.NewSHA1` (`middleware.go:138`) is an *identifier-derivation* - use (namespaced sub→UUID), not a security MAC — benign. ✔ -- **Trust anchor & key lifecycle:** verification keys are pulled from the JWKS URL - over HTTPS, cached 1h with stale-fallback (`middleware.go:215-283`). **Weaknesses - that violate INV-13:** (a) no minimum modulus check and exponent truncated via - `Int64()` (`middleware.go:285-298`) → a downgraded/mis-served small key is - trusted; (b) no key-set pinning beyond TLS; (c) no `singleflight` on refresh. -- **What is correctly *out of scope* (delegated):** password hashing (Socrate - server bcrypt, `socrate/admin.go:51`), encryption at rest, secret storage, TLS - termination. The architecture cleanly pushes these to the IdP / platform. - -**Crypto verdict:** *correct by minimalism and delegation*; the single defect is -trust-anchor strength validation (INV-13). - ---- - -## 12. Framework Evolution Plan - -Sequenced to convert VIOLATED/PARTIAL invariants to VERIFIED with least churn: - -**Phase A — additive, non-breaking (next minor, v1.x):** -- INV-6: ship `httpware.RequireTenant` (401 on `uuid.Nil`). -- INV-3: `jwtauth.WithRevocationCheck(fn)` option (token_version / introspection). -- INV-11: `url.PathEscape` all `socrate` path segments. -- INV-12/13: `io.LimitReader` on upstream reads; reject `N.BitLen() < 2048`. -- INV-10: `gormlogger` redaction/disable hook (default: no bound values in prod). -- Process: add `govulncheck` + Dependabot to `ci.yml`; bump `golang-jwt` to latest. -- Add the §9 regression suite (failing cases skipped behind a build tag). - -**Phase B — opt-in security, default-off (v1.x+1):** -- INV-2: `jwtauth.WithAudience(...)` available but not yet default. -- INV-7: `Config.Validate()` that warns when no authz middleware is mounted on a - protected group; `jwtauth.New` warns on empty issuer. - -**Phase C — flip defaults, breaking (v2.0.0):** -- INV-2: audience **required** by default. -- INV-5/INV-6: tenant becomes **write-once** in context. -- INV-9: `apierror` stops serializing `Message` for 5xx; introduces `PublicMessage`. -- INV-14: ship an append-only `AuditSink` interface (durable/tamper-evident writer). - ---- - -## 13. Backward-Compatibility Strategy - -- **Compatibility contract:** follow SemVer strictly. All Phase A/B items are - additive (new functions/options) → **no import breakage**; existing callers - compile and behave unchanged. -- **Breaking changes are quarantined to v2.0.0** and pre-announced one minor in - advance via `// Deprecated:` doc markers, mirroring the existing discipline - already visible in the codebase (`ctxutil.go:235-247` keeps `GetTenantTier`/ - `WithTenantTier` aliases). Reuse that exact pattern. -- **Migration aids:** provide `v1`→`v2` shims (e.g. `jwtauth.NewLegacy` that keeps - audience optional) and a `CHANGELOG`/`MIGRATION.md` enumerating each flipped - default and the one-line fix. -- **Feature flags over forks:** every default-flip in Phase C must be reachable as - an explicit opt-out in v2 (e.g. `WithoutAudience()`) so a large consumer can - upgrade the dependency and adopt the stricter defaults incrementally. -- **CI gate:** `go test ./...` of a pinned downstream consumer (Kerplan) in the - release workflow to catch accidental breakage before tagging. - ---- - -## 14. Security Maturity Score - -Scale 0–5 (0 ad-hoc · 3 defined/repeatable · 5 optimised). Levels below are -**post v1.8.0**; the pre-remediation level is shown in parentheses. - -| Dimension | Level (v1.8.0) | Basis | -|-----------|:-----:|-------| -| Secure design | **4** (was 3) | Controls provided for every Critical; fail-closed gates; minimal crypto | -| AuthN/AuthZ completeness | **3** (was 2) | aud + revocation + issuer-warning shipped; object-level & mandatory-authz still gaps | -| Invariant enforcement | **4** (was 2) | **0 violated**, 2 partial, 3 verified✱(opt-in), 8 verified (§6) | -| Crypto architecture | **4** (was 3) | Delegated + pinned; min key size enforced (#19) | -| Supply-chain assurance | **3** (was 2) | `govulncheck` clean on Go 1.26.4, deps current (#11/#13); still **no SCA job in CI** | -| Verification/testing | **3** (was 2) | Negative tests for aud/revocation/key-size/oversized-JWKS/path-escape/redaction; `alg=none` still missing | -| Observability/audit | **3** (was 2) | Good correlation; opt-in SQL redaction (#27) closes log leakage; no native audit writer | -| Process (CI/lint/deprecation) | **4** (—) | race+vet+lint, SemVer + changelog discipline, shipped v1.8.0 + v1.9.0 cleanly | -| **Overall maturity** | **≈3.6 / 5** (was 2.5) | "Hardened; zero violated invariants; default-on guarantees + native audit pending v2.0" | - -## 15. Enterprise Readiness Score - -Post v1.8.0 + v1.9.0 (pre-remediation score in parentheses): - -| Use as… | Score /10 | Rationale | -|---------|:---------:|-----------| -| Trusted **dependency / component** (app supplies compensating controls) | **8** (was 7) | Controls present in-framework; remaining gaps documented | -| **Sole** security foundation, single-tenant SaaS | **8** (was 6) | All High + Medium closed; in-memory rate limit accepted single-instance | -| **Sole** security foundation, **multi-tenant enterprise** | **7** (was 4) | INV-2/3/6 controls present but opt-in (must be enabled); distributed rate-limit store still open | -| Regulated (HIPAA/PCI/Gov) | **5** (was 3) | Revocation, key-strength, log minimisation, error redaction addressed; native audit durability still unmet | - ---- - -## 16. Final Recommendation - -> **If this framework were open-sourced today, would I recommend it as the -> security foundation of multiple enterprise SaaS products?** - -**As a published *component library*: yes, with a documented -shared-responsibility model. As the *sole security foundation* for multiple -enterprise multi-tenant SaaS products: not yet — not until the four violated -invariants are closed.** - -The architecture is honest and well-shaped: a single, tiny, correctly-delegated -cryptographic core (RS256, alg-pinned at two layers, `middleware.go:193,199-201`), -fail-closed authentication (`middleware.go:106-118`), un-forgeable in-process -identity (`ctxutil.go:20-39`), crash containment without disclosure -(`recover.go:18-23`), strong response headers (`security.go:17-29`), and a mature -engineering process (race+vet+lint CI `ci.yml:34-51`, SemVer/deprecation -discipline `ctxutil.go:235-247`). Those are exactly the traits I want in a -dependency. - -But a *security foundation* is judged on its invariants, and four are provably -violated in code: **no audience binding** (INV-2/F-1), **no revocation** -(INV-3/F-2), **fail-open tenant isolation** (INV-6/F-3), and **bypassable abuse -control** (INV-8/F-4) — each demonstrated by an attack tree in §7 that succeeds -against the *default* configuration. Defense-in-depth at the core is shallow -(§5): every one of these has exactly one barrier, and that barrier is optional, -absent, or bypassable. Compounding it, the auth trust root is pinned to a -JWT library one security-patch behind, with no SCA in CI to ever notice (§10). - -The encouraging part is that **none of this is a redesign.** §12 converts every -violated invariant to VERIFIED through additive v1.x work plus one curated v2.0 -default-flip, and §9 gives the executable regression suite that keeps them closed. -Ship those, add `govulncheck` and the audience/tenant/revocation defaults, and -this becomes a framework I *would* endorse as an enterprise security foundation. - -**Recommendation: APPROVE AS A DEPENDENCY behind a shared-responsibility doc; -WITHHOLD as a sole multi-tenant enterprise security foundation until INV-2, -INV-3, INV-6, and INV-8 are enforced.** - -### Revised standing — post v1.8.0 + v1.9.0 (2026-06-20) - -The entire v1.x roadmap anticipated above **has shipped.** v1.8.0 provided every -previously-violated control — audience binding (`WithAudience`, #7), revocation -(`WithRevocationCheck`, #17), tenant enforcement (`RequireTenant`, #15) — plus -default-on weak-key rejection (#19). **v1.9.0 closed every Medium and the last -VIOLATED invariant:** socrate path-escaping (#23, INV-11 → VERIFIED), bounded -upstream reads (#25, INV-12 → VERIFIED), gormlogger SQL redaction (#27, INV-10), -5xx error redaction (#29, INV-9 → VERIFIED), and the empty-issuer warning (#31). -**No invariant is VIOLATED.** INV-8 is the lone abuse-control caveat (distributed -rate-limit store still pending). `govulncheck` passes on Go 1.26.4 with -`golang-jwt v5.2.2`. The §7 attack trees no longer succeed against a correctly -configured deployment — their "KILL" lines are shipped code. - -The one structural caveat that remains: INV-2/INV-3/INV-6 are **opt-in** (the -`✱` in §6), so the guarantee holds only when each app enables them. The remaining -v2.0 work is the default-flip (audience required, tenant write-once), a distributed -rate-limit store (INV-8), a native audit writer (INV-14), and a `govulncheck` CI -job. - -**Revised recommendation: APPROVE AS A DEPENDENCY, and APPROVE as a multi-tenant -enterprise security foundation *for any service that enables the shipped controls* -(`WithAudience` + `WithRevocationCheck` + `RequireTenant`). With all High and -Medium findings closed, full by-default endorsement awaits only the v2.0 -default-flips.** - -### Verification limits (no speculation) -The following are **delegated trust** and cannot be verified from this repo — -they must be confirmed in the Socrate server and deployment config before any -"yes" upgrades: that Socrate issues scoped `aud` and `tenant_id` and honours -`token_version`; that TLS terminates in front of the binary and the :8081 admin -port is network-isolated (`client.go:19-21`); and that each consuming app actually -mounts RBAC/Gate/RequireTenant on every protected route. diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md deleted file mode 100644 index 980240c..0000000 --- a/SECURITY-AUDIT.md +++ /dev/null @@ -1,594 +0,0 @@ -# Deep Security & Architecture Audit — `backendkit` - -**Scope:** `github.com/ovander/backendkit` (Go 1.25, commit `46105b1`) -**Method:** Full read of every exported package. Every conclusion below cites -`file:line` evidence from this repository. Where a conclusion cannot be reached -from code in this repository, that is stated explicitly. -**Auditor framing:** This package is treated as the shared security foundation -for multiple production SaaS apps (README §"Used by" — Kerplan, a multi-tenant -enterprise SaaS, `README.md:711`). Every exported package is assumed -security-critical until proven otherwise. - -> **⚠️ Remediation update — 2026-06-20 (post v1.8.0 / v1.9.0).** This audit was -> written against commit `46105b1` (pre-remediation); the original analysis is -> preserved for the record. Two remediation cycles have since shipped: -> **v1.8.0** — F-1, F-2, F-3, F-10 and the supply-chain items; **v1.9.0** — -> F-5, F-7, F-8, F-9, F-17. **Every High and Medium finding is now addressed** -> (F-4 partially: the rate limiter still needs a distributed store). `govulncheck` -> is clean on Go 1.26.4. Status is annotated in the table below and in §16. - ---- - -## 1. Executive Summary - -`backendkit` is a **well-engineered set of building blocks**, not a turnkey -secure framework. Its cryptographic core (RS256 JWT verification) is implemented -correctly and fails closed on the cases it handles. The Go is idiomatic, context -propagation is type-safe, and panic recovery / error shaping are clean. - -However, **as a *security foundation* it has structural gaps that matter at -enterprise scale.** The most serious are not bugs in the code that exists, but -**security controls a buyer would assume are present and that are not**: - -| # | Severity | Finding | Origin | Status (v1.8.0) | -|---|----------|---------|--------|-----------------| -| F-1 | **High** | JWT **audience (`aud`) is never validated** — tokens are reusable across every app sharing the Socrate issuer | Framework | ✅ **Fixed** — `jwtauth.WithAudience` opt-in (#7); default-on planned for v2.0 | -| F-2 | **High** | **Token revocation / `token_version` is never enforced** on the hot path | Framework gap | ✅ **Fixed** — `jwtauth.WithRevocationCheck` opt-in (#17) | -| F-3 | **High** | **Tenant isolation is fail-open and absent by default** (`tenant_id` not issued by default server; `uuid.Nil` flows downstream) | Framework + Config | ✅ **Fixed** — `httpware.RequireTenant` opt-in (#15) | -| F-4 | **High** | **Per-tenant rate limiter is a no-op in the default configuration** and fails open | Framework + Config | ◑ **Partial** — `RequireTenant` (#15) blocks nil-tenant upstream; limiter itself unchanged (in-memory, per-tenant) | -| F-5 | Medium | Issuer validation is optional and silently disabled when empty | Framework | ✅ **Fixed** — `jwtauth.New` warns on empty issuer (#31, v1.9.0); mandatory = v2.0 | -| F-7 | Medium | **Path-parameter injection** in `socrate.Client` (unescaped path segments in URLs) | Framework | ✅ **Fixed** — `url.PathEscape` now applied to every caller-supplied path segment across the full `client.go` / `admin.go` / `monitoring.go` / `alerts.go` / `reports.go` surface. The v1.9.0 change (#23) escaped only `client.go`; the remaining admin/monitoring/alerts/reports methods (M-1) are now escaped too. | -| F-8 | Medium | Unbounded body reads (JWKS, Socrate, AI gateway) — memory-exhaustion vector | Framework | ✅ **Fixed** — `io.LimitReader` caps (#25, v1.9.0) | -| F-9 | Medium | `gormlogger` logs **full SQL with bound parameter values** (PII/secrets in logs) | Framework | ✅ **Fixed** — opt-in `WithSQLRedaction` (#27, v1.9.0) | -| F-10 | Medium | JWKS parser accepts **weak RSA keys** (no min modulus, exponent truncated) | Framework | ✅ **Fixed** — min 2048-bit + exponent validation (#19) | -| F-17 | Medium | `apierror.Message` documented "logs only" but **serialized to clients** | Framework | ✅ **Fixed** — 5xx message/details redacted (#29, v1.9.0) | -| SC | Medium | Stale `golang-jwt v5.2.1` pin + Go stdlib CVEs (supply chain, see §10/§16) | Framework | ✅ **Fixed** — `golang-jwt v5.2.2` (#13), Go 1.26.4 toolchain (#11) | - -**Bottom line:** the pieces that exist are mostly sound; the framework is -**incomplete relative to its own marketing** ("complete middleware stack", -`README.md:178`, `205`). It can be a *trusted dependency* once the gaps below are -closed and the shared-responsibility boundary is documented. It cannot today be -relied on as the *sole* security layer of a regulated multi-tenant SaaS. - ---- - -## 2. Phase 1 — Security Boundary Map - -Packages participating in security, ranked by criticality: - -| Package | Security role | Trust boundary | -|---------|---------------|----------------| -| `jwtauth` | **Authentication** — RS256 JWT verification, JWKS fetch/cache, claim→context | The primary boundary: converts an untrusted bearer token into trusted identity | -| `ctxutil` | **Identity propagation** — typed, unexported context keys for tenant/user/role/plan/jwt | Internal; integrity depends on only `jwtauth` writing it | -| `httpware/rbac.go` | **Authorization** — role→permission gate | Per-route, opt-in | -| `httpware/ratelimit.go` | **Abuse control** — per-tenant token bucket | Post-auth | -| `httpware/security.go` | **Security headers** | Response hardening | -| `httpware/recover.go` | **Panic containment** | Prevents stack leakage / crash | -| `httpware/{requestid,logger,timeout,bodylimit}.go` | Correlation, audit surface, DoS limits | Supporting | -| `tiering` | **Plan-based authorization** (Gate + PolicyService) | Commercial entitlement, treated as authz | -| `socrate` | **Identity-provider client** — forwards JWT / service creds to Socrate | Outbound trust to IdP | -| `aigateway` | Outbound calls to OpenAI/Anthropic; holds API keys | Secret handling, egress | -| `gormlogger` | DB log routing | Sensitive-data sink | -| `apierror` | Error shaping → client | Information-disclosure boundary | -| `buildinfo` | Public version endpoint | Intentionally unauthenticated (`buildinfo.go:51`) | - -**Not present anywhere in the package** (confirmed by exhaustive grep): **CORS, -CSRF, TLS configuration, session/cookie handling, password hashing, encryption -at rest, secrets management, signed audit log.** These are therefore *outside* -backendkit's trust boundary — see Phase 15. - ---- - -## 3. Phase 2 — Authentication Audit (`jwtauth`) - -### Controls that are correct (evidence) - -- **Algorithm confusion / `alg=none` blocked — twice.** - `jwt.WithValidMethods([]string{"RS256"})` (`middleware.go:193`) **and** an - explicit `t.Method.(*jwt.SigningMethodRSA)` assertion in the keyfunc - (`middleware.go:199-201`). An attacker cannot downgrade to HMAC-using-public-key - or `none`. -- **`kid` required** (`middleware.go:202-205`) — no key-guessing. -- **Signature, `exp`, `nbf` validated** — golang-jwt v5 enforces `exp`/`nbf` by - default; signature via the JWKS key. Tampered token → 401 (test - `middleware_test.go:141-166`). -- **Fail-closed**: missing/invalid token → `401` via `apierror.Unauthorized` - (`middleware.go:106-118`). No anonymous fallthrough. -- **JWKS availability**: stale-cache fallback on refresh failure - (`middleware.go:225-231`) — good for uptime. -- **Identity cannot be spoofed via headers**: all identity in `ctxutil` is written - *only* from validated claims (`middleware.go:120-169`); context keys are an - unexported type (`ctxutil.go:20-39`), so a client header cannot inject them. - -### Findings - -**F-1 (HIGH) — No audience (`aud`) validation.** -`validateToken` sets only `WithValidMethods` and *optionally* `WithIssuer` -(`middleware.go:191-207`). There is **no `jwt.WithAudience`**. Because the design -explicitly states *"All backends sharing Socrate as their OAuth2 provider"* -(`ctxutil.go:5`) use the same issuer and JWKS, a token minted for App A is -cryptographically valid at App B. This is a classic **confused-deputy / cross-service -token-replay** exposure. In a fleet of SaaS apps behind one IdP, this is the -single most important missing check. -*Fix:* add a required-audience parser option keyed on each app's client_id. - -**F-2 (HIGH) — Revocation / `token_version` never enforced.** -The middleware extracts `token_version` and stores it in context -(`middleware.go:163-165`) and the type doc claims it is *"incremented on password -change / token revocation"* (`middleware.go:36`). **Nothing ever compares it to a -current value.** `validateToken` checks only signature + standard claims. A stolen -or post-logout access token remains valid until `exp`. `socrate.IntrospectToken` -(RFC 7662, `client.go:741`) exists but is **never wired into the auth path**. -Net: the framework offers no revocation, no token binding, no replay protection. -*Fix:* optional introspection or a `token_version` callback hook on the hot path. - -**F-5 (MEDIUM) — Issuer validation optional, fail-open on misconfig.** -Issuer is enforced only when non-empty (`middleware.go:194-196`). -`jwtauth.New(jwksURL, "", logger)` silently disables it, and the constructor does -not warn/reject (`middleware.go:92-101`). A copy-paste of the quickstart, which -passes `""`, ships with issuer checking off. - -**F-10 (MEDIUM) — Weak-key acceptance in JWKS parsing.** -`parseRSAPublicKey` (`middleware.go:285-298`) builds the key with -`E: int(new(big.Int).SetBytes(eBytes).Int64())` (truncates a large exponent) and -accepts **any modulus size** — no rejection of <2048-bit keys. Safety rests -entirely on TLS integrity of the JWKS endpoint; a mis-served or downgraded key is -trusted. *Fix:* enforce `pub.N.BitLen() >= 2048` and sane exponent. - -**F-8 (part) (MEDIUM) — Unbounded JWKS body.** -`fetchJWKS` decodes `resp.Body` with no size cap (`middleware.go:255-258`). The -HTTP client has a 10s timeout (good, `middleware.go:97`) but no -`http.MaxBytesReader`; a compromised/MITM JWKS endpoint can feed a huge body. - -**F-18 (LOW) — No single-flight on JWKS refresh.** -On cache expiry, every concurrent request that misses calls `fetchJWKS` -(`middleware.go:215-240`) — a thundering herd toward the IdP. Functionally safe, -operationally noisy. - -**Clock skew (LOW):** no `jwt.WithLeeway` configured — default 0s. Tight clocks -across services can cause spurious 401s; not a vulnerability. - -**Test coverage gap (Testability):** negative-path tests cover missing header and -tampered token only (`middleware_test.go:122-166`). **No tests for `alg=none`, -algorithm confusion, expiry, issuer enforcement, or missing `kid`** — the exact -controls a security reviewer most wants pinned. - ---- - -## 4. Phase 3 — Authorization Audit (`httpware/rbac.go`, `tiering`) - -- **Centralised?** Partially. RBAC and the tiering Gate are middleware, but they - are **opt-in per route** (`r.Use(rbac.Require(...))`, `README.md:259-265`). - Nothing forces a route to carry an authz check — **a handler can silently ship - with no authorization** ("missing function-level access control" is a - *structural* possibility, not prevented by the framework). -- **Fail-closed:** unknown/empty role → `false` → 403 (`rbac.go:57-67`). Unknown - plan → lowest tier (`plan.go:67-83`, `gate.go:50`). Good defaults. -- **Object-level / tenant-scoped authorization:** **none.** RBAC is purely - role→permission (`rbac.go:41-68`); there is no resource-ownership or - per-object check. **IDOR / BOLA prevention is entirely the application's job.** -- **Role source confusion (MEDIUM-ish):** `RBAC.Require` reads only the top-level - `Role` claim via `GetUserRole` (`rbac.go:44`). The token also carries a richer - `app_roles` map (`middleware.go:160-162`, `ctxutil.go:149-171`). An app that - *thinks* it is enforcing the per-app role but uses `Require` is actually keying - on the global `role` — a latent **role-confusion** bug the API shape invites. -- **Composability:** permissions are a flat `[]Permission` per role; no - inheritance, no permission composition, no wildcard. Simple and predictable, but - every role must enumerate every permission (`rbac.go:25`, `57-67`). -- **Testability:** RBAC is a pure function of context + map — easily unit-tested. - -`PolicyService` (entitlement authz) is sound: DB-backed, cached, **busts cache on -write** (`policy_service.go:97`), and **denies on any error** (`policy_service.go:104-119`). -It serves stale on DB outage (`policy_service.go:165-172`) — an availability vs. -correctness trade that is acceptable for *entitlements* but would be wrong for -*security* authz. - ---- - -## 5. Phase 4 — Multi-Tenant Security - -**F-3 (HIGH) — Tenant isolation is absent by default and fails open.** - -- Tenant ID is set **only when the `tenant_id` claim is present** - (`middleware.go:122-131`). -- The code's own documentation states the **default Socrate server does not issue - `tenant_id`** (`middleware.go:38-40`, `51-53`). -- When absent, `GetTenantID` returns `uuid.Nil` (`ctxutil.go:49-54`) with no error. -- **No middleware requires a tenant.** Any downstream query that trusts - `GetTenantID` will silently scope to the *nil* tenant — i.e. potentially **all - tenants share one bucket**. - -Consequences chain into F-4 (rate limiter keyed on the nil tenant). Spoofing of -the tenant *value* by a client is not possible (it comes from the signed JWT), but -**the absence of the value is the danger**, and there is no -guard rail. The framework provides **no tenant-scoped repository, no -"require tenant" middleware, and no background-job context propagation** — all of -that is the application's responsibility, undocumented as such. - -Context can also be **overwritten** by any code holding the `ctx` -(`ctxutil.WithTenantID` is exported, `ctxutil.go:44`); there is no write-once -guarantee. Within a single trusted binary this is acceptable, but it means tenant -integrity is a *convention*, not an *invariant*. - ---- - -## 6. Phase 5 — Middleware Review - -Documented chain (`README.md:177-179`, `243-252`): -`RequestID → Logger → SecurityHeaders → BodyLimit → Recover → Timeout → auth → RateLimiter`. - -- **Order is mostly correct:** `Recover` wraps `Timeout`, `auth`, and - `RateLimiter`, so panics in those are caught. **But `RequestID`, `Logger`, - `SecurityHeaders` sit *outside* `Recover`** — a panic in those three is *not* - contained (low risk; they are trivial). -- **F-11 (LOW) — `Timeout` drops cancellation.** `context.WithoutCancel` - (`timeout.go:25`) strips the parent deadline **and client-disconnect / shutdown - cancellation**. Documented as intentional for slow AI endpoints - (`timeout.go:14-20`), but it means a disconnected client's work runs to the full - timeout, and graceful-shutdown cancellation won't propagate. It is also - **advisory only** — it never forces a response (no `http.TimeoutHandler`), so a - handler ignoring `ctx` runs unbounded. -- **`BodyLimit`** (`bodylimit.go:14-21`) correctly uses `http.MaxBytesReader` — - good DoS control. No *response*-size limit (not usually needed). -- **`RequestID`** (`requestid.go:17-27`) accepts a client-supplied - `X-Request-ID` verbatim and echoes it — fine for correlation, but a client can - set arbitrary values (log-forging is mitigated by structured logging). -- **`Recover`** (`recover.go:14-28`) logs panic + stack **server-side only** and - returns a generic 500 — **no stack trace leaked to the client.** Correct. -- **`SecurityHeaders`** — see Phase 7. -- **Missing middleware:** **no CORS, no CSRF, no gzip/compression bomb guard, no - concurrency limiter.** For a bearer-token API, CSRF is largely N/A, but CORS is a - real omission relative to the "complete stack" claim. - ---- - -## 7. Phase 6 — Cryptography Review - -- **No password hashing, encryption, HMAC, or nonce/IV handling in this package.** - Comment in `socrate/admin.go:51` references server-side bcrypt — that is the - *Socrate server's* concern, not backendkit. -- **Randomness:** identifiers use `github.com/google/uuid` (`requestid.go:22`, - `middleware.go:138`), which draws from `crypto/rand` for v4 — adequate. The only - `math/rand`/`crypto/rand` import is in a *test* (`middleware_test.go:4`). -- **Deterministic UUID from `sub`** (`middleware.go:138`) uses `uuid.NewSHA1` - (namespaced) — a stable identifier mapping, **not** a security token; SHA-1 use - here is benign. -- **No constant-time comparisons needed** — no secret comparisons happen in this - package (token verification is delegated to golang-jwt, which is constant-time - for signatures). -- **No hardcoded secrets** (confirmed by grep): API keys/secrets are injected via - config structs (`aigateway` Config `client.go:34-48`; `socrate` ClientConfig - `client.go:70-78`) and **never logged** — `aigateway` logs only - `"configured"/"not configured"` (`client.go:91-95`). - -Cryptography verdict: **correct by delegation and omission.** No weak primitives. - ---- - -## 8. Phase 7 — Configuration Security - -- **No environment variables read inside any package** (by design — `aigateway` - doc `client.go:5-7`); config is injected. Good for testability and 12-factor. -- **Safe-ish defaults:** HTTP timeouts default (`socrate` 30s `client.go:89-92`, - `aigateway` 30s `client.go:67-70`, JWKS 10s `middleware.go:97`). -- **Dangerous default:** `jwtauth.New` accepts empty issuer with **no validation** - (F-5). `NewClient` validates `BaseURL`/`ClientID` (`client.go:82-87`) but not - much else. -- **No production/startup validation harness** — there is no `Validate()` that - refuses to boot on insecure config (e.g. empty issuer, missing audience). For an - enterprise framework this is a notable gap. -- **`MagicLinkResponse.MagicURL`** is populated by the *server* in dev mode only - (`client.go:780-785`) — backendkit merely passes it through; no client-side debug - bypass exists here. - ---- - -## 9. Phase 8 — Logging & Audit - -- **No JWT, password, secret, or Authorization header is logged** anywhere - (grep-confirmed). `jwtauth` logs only error context (`middleware.go:108,115`); - `Logger` logs method, **path only (not query string)**, status, duration, - tenant_id, request_id (`logger.go:34-49`) — query strings (which may carry - tokens) are deliberately excluded. Good. -- **F-9 (MEDIUM) — `gormlogger` logs full SQL with bound values.** - `Trace` logs `sql` from GORM's callback, which includes **interpolated parameter - values** (`gormlogger/logger.go:88-95`), at Warn for slow queries (the - *production* default level, doc `logger.go:31`) and at Error. This routinely - writes **PII (emails, names) and any secret stored in a row** to logs. No - redaction hook. This is the most likely real source of a "sensitive data in - logs" application finding. -- **Audit trail:** backendkit has **no audit-log writer of its own.** It can - *read* Socrate's audit events (`socrate.GetActivityLogs`, `client.go:835`) but - provides **no tamper-evident, append-only, durable audit log**. Audit integrity, - completeness, and durability are entirely Socrate's / the app's responsibility. -- **Correlation:** `request_id` propagation is solid (`requestid.go`, `logger.go`, - `ctxutil.go:220-233`). - ---- - -## 10. Phase 9 — Error Handling - -- **No stack traces or internal errors leak to clients** from `Recover` - (`recover.go:18-23`). 500s are generic. -- **F-17 (MEDIUM) — `apierror.Message` contradicts its own contract.** - The struct comment says `Message` is the *"English dev-facing message - (logs only)"* (`errors.go:15`), but `WriteJSON` **serializes `Message` directly - to the client** (`errors.go:13`, `186-190`). A developer trusting the comment who - writes `apierror.Internal(err.Error())` will leak the internal error to the - caller. The misleading doc makes information disclosure *likely*. Several - constructors already embed identifiers in the message (`NotFound` → - `entity + " not found: " + id`, `errors.go:43-49`). -- HTTP status mapping is consistent and correct (`errors.go:154-183`). -- Errors are consistently structured (`ErrorResponse{Error: AppError}`), - i18n-keyable (`WithKey`, `errors.go:26-29`), good DX. - ---- - -## 11. Phase 10 — API Security - -- **SQL construction:** **none in backendkit.** `tiering` defines GORM models and a - `PolicyRepository` *interface* (`policy.go:31-37`, `policy_service.go:20-24`); the - concrete SQL lives in the app. No injection surface here, but no parameterisation - guarantee provided either. -- **F-7 (MEDIUM) — Path-parameter injection in `socrate.Client`.** Caller-supplied - path segments were interpolated into request paths with `+` / `fmt.Sprintf` and - **no escaping**. A segment containing `/`, `?`, or `..` rewrites the target - endpoint (e.g. `userID = "1/reset-password"` hits a different route, or - `?role=admin` injects a query). Note the contrast: `search` *is* - `url.QueryEscape`d (`client.go:415`), so the omission on path segments was - inconsistent. *Fix:* `url.PathEscape` every dynamic path segment. - **Status: resolved (see M-1 below).** The v1.9.0 change (#23) escaped only - `client.go`; the follow-up (M-1) extends `url.PathEscape` to every remaining - caller-supplied segment across `admin.go` (app/user/superadmin/blocked-ip/ - ip-reputation/log IDs), `monitoring.go` (user sessions), `alerts.go` (rule and - alert IDs) and `reports.go` (report IDs). Internal, trusted values resolved from - the server (e.g. the app ID from `getAppID`) are intentionally left unescaped, as - in `client.go`. -- **SSRF:** base URLs are operator-config, not user-input (`client.go:55-60`, - `aigateway.go:79-80` are hardcoded to the real providers) — low SSRF risk, but - F-7 allows partial path control. -- **F-8 (MEDIUM) — Unbounded response reads** in `socrate.readBody` - (`client.go:172-175`, `io.ReadAll`) and `aigateway` (`client.go:173, 227`). A - compromised upstream can exhaust memory. -- **Mass assignment / deserialization:** request/response structs are explicit and - typed (`socrate` data types `client.go:309-401`); no `map[string]interface{}` - binding of client input, no `encoding/xml`, no unsafe reflection. -- **No open redirect / template / command injection** surfaces in this package. - ---- - -## 12. Phase 11 — Go Security & Concurrency - -- **Mutex/RWMutex usage correct:** `jwtauth` (`middleware.go:85, 216-235, 276-279`), - `RateLimiter` (`ratelimit.go:42, 92-102`), `PolicyService` double-checked cache - (`policy_service.go:149-177`), `socrate` service-token (`client.go:258-263`). No - obvious data races; the package doc even claims "no shared mutable state between - requests" (`timeout.go:1-4`). -- **Goroutine lifecycle:** `RateLimiter` starts a cleanup goroutine - (`ratelimit.go:58, 68-79`) with a `Stop()` (`ratelimit.go:64-66`). **`Stop()` - closes the channel with no guard — calling it twice panics** (`close` of closed - channel). Minor (LOW). -- **`socrate.getServiceToken` holds the mutex across the HTTP round-trip** - (`client.go:258-302`) — correctness-safe but serializes all callers behind one - network call (perf, not security). -- **No `unsafe`, no `reflect` misuse, no pointer aliasing, no obvious resource - leaks** — response bodies are consistently closed (`readBody` `client.go:172-175`, - `defer resp.Body.Close()` throughout). `defer cancel()` paired correctly - (`timeout.go:26-27`). - ---- - -## 13. Phase 12 — Framework Architecture - -- **Package organization:** clean, single-responsibility packages; dependency - direction is acyclic (`ctxutil` and `apierror` are leaves; everything depends - inward on them). Good. -- **Coupling:** low. Middleware are plain `func(http.Handler) http.Handler`, - router-agnostic (`README.md:342`). `ctxutil` is the one shared coupling point and - it is deliberately the *single source of truth* for keys (`ctxutil.go:5-11`) — - the right call. -- **Public API:** small, idiomatic, well-documented with runnable examples - (`example_test.go` in most packages). -- **Extensibility:** `tiering.PolicyRepository`/`PlanSelector` interfaces - (`policy.go:63-91`) and pluggable `RoleMap` are good seams. But **no seam to - inject audience/revocation policy into `jwtauth`** — the auth core is closed to - the most security-relevant extension. -- **Versioning/compat:** module is pre-1.0 in spirit but README references v1.7.0 - installs; deprecated aliases are kept (`ctxutil.go:235-247`) — backward-compat - discipline is present. -- **Dependencies:** minimal and reputable (`golang-jwt/v5`, `google/uuid`, - `logrus`, `golang.org/x/time`, `gorm`) — `go.mod` is lean. - ---- - -## 14. Phase 13 — Framework Quality Scores - -| Dimension | Score /10 | Justification (evidence) | -|-----------|:--------:|--------------------------| -| Architecture | 8 | Acyclic, low-coupling, clean seams; closed auth core (Phase 12) | -| **Security** | **5** | Correct crypto core, but F-1/F-2/F-3/F-4 are structural gaps | -| Go idioms | 9 | Type-safe context, correct sync, no `unsafe`, bodies closed | -| API design | 8 | Small, documented, runnable examples; F-17 doc/behaviour mismatch | -| Maintainability | 8 | Readable, consistent, deprecation discipline | -| Extensibility | 6 | Good repo/selector seams; no auth-policy seam | -| Operational readiness | 5 | In-memory-only rate limit, no startup validation, stale-cache trades | -| Documentation | 7 | Excellent prose; but overstates completeness ("complete stack") | -| Testability | 7 | Pure functions; thin negative-path auth tests | -| **Overall** | **6** | Solid components, incomplete as a security foundation | - ---- - -## 15. Phase 14 — Production Readiness - -| Target | Verdict | Why (evidence) | -|--------|---------|----------------| -| Startup production (single-tenant) | **Conditional yes** | Auth core is sound; fix F-5/F-17, accept in-memory rate limit | -| **Enterprise SaaS (multi-tenant)** | **No, not as-is** | F-1 (cross-app token reuse), F-3 (tenant fail-open) are disqualifying without app-side compensation | -| Healthcare (HIPAA) | **No** | F-9 (PII in SQL logs), F-2 (no revocation), no audit durability | -| Financial services | **No** | F-2 (no revocation/replay), F-1 (audience), F-9 | -| Government | **No** | Missing key-size enforcement (F-10), no FIPS posture, no revocation | -| **SOC 2** | **Partial** | Logging/correlation good; but no native audit trail, F-9 leakage, revocation gap are auditor findings | -| ISO 27001 | **Partial** | Same as SOC 2 | -| PCI DSS | **No** | Revocation, key strength, log-data-minimisation (F-9) all required and unmet | -| Multi-region | **No (rate limit)** | `RateLimiter` is in-memory per-instance (`ratelimit.go:43`) → limit = instances × rps; not coordinated | -| Multi-tenant SaaS | **No, not alone** | F-3 + F-1 + F-4 must be closed or compensated app-side | - -These verdicts are about **backendkit as the *sole* control layer**. With a -correctly configured Socrate server (issuing `tenant_id`, scoped audiences) and -app-side compensating controls, several upgrade to "yes" — see Phase 15. - ---- - -## 16. Phase 15 — Trust Boundary Verification - -Mapping each application-audit finding category to its true origin: - -| Application finding | Origin | Why (evidence) | -|---------------------|--------|----------------| -| JWT validation weak / no `aud` | **Framework bug** | `middleware.go:191-207` — no `WithAudience` | -| `alg=none` / alg confusion | **False positive** | Double-blocked `middleware.go:193, 199-201` | -| Tokens valid after logout/password change | **Framework gap** | `token_version` captured, never checked `middleware.go:163-165` | -| Cross-app token reuse | **Framework bug** | F-1, shared issuer `ctxutil.go:5` | -| Missing authorization on a route | **Application bug** | RBAC is opt-in `README.md:259`; framework can't force it | -| IDOR / object-level access | **Application bug** | No object-level authz exists in framework (Phase 4) | -| Role/permission confusion (app vs global role) | **Shared** | API invites it: `Require` uses global `Role` `rbac.go:44` while `app_roles` exists | -| Cross-tenant data access | **Shared / Config** | F-3: framework fail-open + server not issuing `tenant_id` | -| Rate limiting ineffective | **Framework + Config** | F-4: nil-tenant bypass `ratelimit.go:108-112`; in-memory only | -| PII / secrets in logs | **Framework bug** | F-9 `gormlogger/logger.go:88-95` | -| Internal error leaked to client | **Framework bug (latent)** | F-17 `errors.go:15` vs `186-190` | -| Stack trace in response | **False positive** | `Recover` returns generic 500 `recover.go:18-23` | -| Missing security headers | **False positive** | `SecurityHeaders` present and strong `security.go:17-29` | -| CORS misconfiguration | **Application bug** | No CORS in framework — entirely app-owned | -| CSRF | **Mostly N/A** | Bearer-token API, no cookie handling in framework | -| SSRF / path manipulation in IdP calls | **Framework bug** | F-7 unescaped path params `client.go:443…592` | -| Weak randomness | **False positive** | `crypto/rand`-backed UUIDs (Phase 6) | -| Hardcoded secrets | **False positive** | Injected config, never logged `aigateway client.go:91-95` | -| Panic crashes service | **False positive** | `Recover` middleware `recover.go` | -| Audit trail missing/tamperable | **Shared (Socrate/app)** | Framework only reads logs `client.go:835`; no writer | - ---- - -## 17. Phase 16 — Remediation Roadmap - -> **Shipped in v1.8.0 (2026-06-20):** F-1 (#7), F-2 (#17), F-3 (#15), F-10 (#19), -> plus the supply-chain fixes — `golang-jwt v5.2.2` (#13) and the Go 1.26.4 -> toolchain clearing 11 stdlib CVEs (#11). Items below are annotated ✅/◑/◻. - -### Critical (do before any multi-tenant enterprise use) -1. ✅ **F-1** Enforce audience — **shipped v1.8.0 (#7)** as the opt-in - `jwtauth.WithAudience(clientID)`. Default-off (non-breaking); making it - default/required remains a **v2.0** item. -2. ✅ **F-3** `RequireTenant` middleware that 401s when `GetTenantID == uuid.Nil` - — **shipped v1.8.0 (#15)** as `httpware.RequireTenant`. Tenant-scoped repos - filtering on it remains application responsibility (documented). -3. ✅ **F-2** Revocation hook — **shipped v1.8.0 (#17)** as the opt-in - `jwtauth.WithRevocationCheck(fn)` (token_version / denylist / introspection). - -### High -4. ◑ **F-4** Partially mitigated by `RequireTenant` (#15), which rejects - nil-tenant requests before the limiter. **Still open:** IP/subject fallback key - and a pluggable distributed (Redis) store for multi-instance correctness. -5. ✅ **F-5** `jwtauth.New` now warns on an empty issuer — **shipped v1.9.0 (#31)**. - Making issuer mandatory (reject/validate) remains a v2.0 default-flip. -6. ✅ **F-17** `WriteJSON` redacts `Message`/`Details` for 5xx — **shipped v1.9.0 - (#29)**. (4xx unchanged; behaviour change for 5xx bodies, noted in CHANGELOG.) - -### Medium -7. ✅ **F-7** `url.PathEscape` on all `socrate.Client` userID segments — - **shipped v1.9.0 (#23)**. -8. ✅ **F-9** Opt-in `gormlogger.WithSQLRedaction()` keeps bound values out of - logs — **shipped v1.9.0 (#27)**. -9. ✅ **F-8** Upstream reads wrapped with `io.LimitReader` (JWKS/socrate/aigateway) - — **shipped v1.9.0 (#25)**. -10. ✅ **F-10** Enforce `pub.N.BitLen() >= 2048` and exponent sanity in JWKS - parsing — **shipped v1.8.0 (#19)**. Default-on (no opt-in). - -### Architecture / non-breaking -11. Add a `Config.Validate()` that refuses insecure boot (empty issuer, missing - audience, debug flags). -12. Add `singleflight` to JWKS refresh (F-18); guard `RateLimiter.Stop()` against - double-close. -13. Expand negative-path auth tests (alg=none, expiry, issuer, kid). -14. Provide first-class **CORS** middleware and an **audit-log writer** interface, or - explicitly document them as out-of-scope. - -### Versioning & migration strategy -- F-1/F-5/F-17 are **breaking** → bundle into a single **v2.0.0**; ship the - audience/issuer/revocation features as opt-in in a **v1.x** minor first - (default-off), with a deprecation window, then flip defaults in v2. -- F-2/F-3/F-7/F-8/F-9/F-10 are **additive/non-breaking** → ship in the next minor. -- Keep the existing deprecated-alias discipline (`ctxutil.go:235-247`) as the model. - ---- - -## 18. Final Recommendation - -> **Update (v1.8.0):** the three Critical items below (F-1, F-2, F-3) and F-10 -> have since shipped as opt-in controls, and the supply-chain issues are cleared. -> The recommendation text below is the original pre-remediation verdict; the -> revised standing is in the callout immediately after it. - -> **Would I recommend `backendkit` as the security foundation for multiple -> enterprise SaaS applications?** - -**Not in its current state — but it is close, and the gaps are well-defined.** - -The evidence is two-sided and I will not overstate either half: - -- **What it does, it largely does correctly.** RS256 verification blocks `alg=none` - and algorithm confusion at two layers (`middleware.go:193, 199-201`), auth fails - closed (`middleware.go:106-118`), panics are contained without leaking stacks - (`recover.go:18-23`), security headers are strong (`security.go:17-29`), context - identity is type-safe and unspoofable via headers (`ctxutil.go:20-39`), and the Go - is clean and race-free. As a *library of trustworthy components*, it earns its - place. - -- **What a security foundation *must* guarantee, it does not yet.** It has **no - audience validation** (F-1), **no token revocation** (F-2), **fail-open tenant - isolation that is absent by default** (F-3), a **rate limiter that no-ops in the - default configuration** (F-4), **PII-leaking SQL logs** (F-9), and a **client/doc - mismatch that invites internal-error disclosure** (F-17). For a *single* app these - are manageable; across a *fleet of multi-tenant enterprise apps behind one IdP*, - F-1 and F-3 in particular are disqualifying until fixed or compensated. - -**Verdict:** Adopt it as a **dependency**, not as the **sole security layer**. -Approve it for production **only after** the three Critical items (F-1, F-2, F-3) -are closed or explicitly compensated in each application, and **only with** a -documented shared-responsibility model that states plainly what backendkit does -*not* cover (CORS, object-level authz, tenant enforcement, audit durability, -distributed rate limiting). With the Phase-16 roadmap applied — a realistic -v1.x-then-v2.0 effort — it can become a framework I *would* recommend as an -enterprise security foundation. - -### Revised standing — post v1.8.0 + v1.9.0 (2026-06-20) - -The entire v1.x roadmap has now shipped across two releases. **v1.8.0** delivered -every Critical control — audience validation (`WithAudience`, #7), revocation -(`WithRevocationCheck`, #17), tenant enforcement (`RequireTenant`, #15) — plus -default-on weak-key rejection (#19) and a clean supply chain (`govulncheck` passes -on Go 1.26.4). **v1.9.0** closed every Medium: socrate path-escaping (#23), -bounded upstream reads (#25), gormlogger SQL redaction (#27), 5xx error redaction -(#29), and the empty-issuer warning (#31). **No High or Medium finding remains -open** (F-4 is partially mitigated — the rate limiter still wants a distributed -store). - -The one structural qualifier left: the three Critical controls (F-1/F-2/F-3) are -**opt-in**, so the guarantee holds **per service that enables them**. Flipping -those to default-on is the remaining v2.0 step that turns "offered" into -"guaranteed". - -**Revised verdict:** adopt as a **dependency**, and — with audience + revocation -configured and `RequireTenant` mounted on tenant-scoped routes — a **defensible -security foundation for multi-tenant SaaS today**. The qualifier is now purely -*"switch the opt-in controls on,"* not *"controls are missing."* Full by-default -endorsement awaits only the v2.0 default-flips. - -### Evidence gaps (stated explicitly, no speculation) -Conclusions I **could not** reach from this repository alone, and what is needed: -- Whether the **Socrate server actually issues `tenant_id`, scoped `aud`, and - honours `token_version`** — requires the Socrate server source / token samples. -- Whether **TLS, secret storage, and network segmentation** (Admin port 8081 is - "internal; restrict at network level", `client.go:19-21`) are enforced — - requires deployment/infra config. -- Whether applications **actually apply** RBAC/Gate/`RequireTenant` on every - sensitive route — requires the consuming applications' router wiring. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0511ed8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security policy + +backendkit sits on the authentication path of every service that uses it: JWT verification, +server-side sessions, CSRF protection and policy enforcement. Security reports are welcome and +handled first. + +## Reporting a vulnerability + +Please use GitHub's **private vulnerability reporting**: the repository's **Security** tab → +**Report a vulnerability**. Do not open a public issue or pull request for a vulnerability. + +Include what you found, how to reproduce it, and the versions you tested +(`go list -m github.com/ovander/backendkit` and `go version`). + +You will get an acknowledgement within a week. Fixes are released as a patch version as soon as +they are ready, and the report is credited in the release notes unless you prefer otherwise. + +## Scope + +- In scope: the packages in this repository, in particular `jwtauth` (token verification and + JWKS handling), `bff` (sessions, cookies, CSRF, PKCE, the session→bearer proxy), `pep`, + `socrate` and `httpware`. +- Out of scope: the Socrate identity provider itself (report those in + [`ovander/go-oauth2`](https://github.com/ovander/go-oauth2)), flaws in an application's own + code or configuration, and denial-of-service by volume. + +## Supported versions + +Only the latest `v1` minor release receives security fixes. Upgrading within `v1` is +backward-compatible. + +## Past reviews + +The library has been through internal security and architecture reviews. The fixes they led to +are listed in [`CHANGELOG.md`](CHANGELOG.md), with the finding IDs they close (`F-n`, `INV-n`). diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index 0e626ad..42c6bdd 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -1,7 +1,7 @@ # Socrate + backendkit — Client Integration Guide A practical, end-to-end guide for **application teams** integrating with the -[Socrate](https://github.com/ovander/socrate) OAuth 2.0 / OpenID Connect server +[Socrate](https://github.com/ovander/go-oauth2) OAuth 2.0 / OpenID Connect server through the `backendkit` library. It is written for two audiences working on the same product: