From 076d1f6926967e070bad02629177a4021d4d2e3c Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 3 Aug 2026 17:04:17 +0200 Subject: [PATCH] Record the authorization vocabulary decision in AGENTS.md --- AGENTS.md | 11 +++++++++++ todo.md | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f9f46af..63adb3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,17 @@ them. Revisit only if the stated reason stops holding. `tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks in place *and* copies across unchanged. Never commit real plugins/config into the root mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise). +- **Authorization vocabulary: `Identity` → `Group` → `Permission`, and there is no `Role`.** Keto + ships no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Identity` matches + Kratos, which owns that record. `Permission` follows RBAC, where a permission is one operation + ("read shifts") and a role is a *bundle* of them; a route gates on one operation, so it gates on + a permission, and a bundle is just a group with several grants (groups nest). Ory's own + "permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier. +- **UI labels stay in ordinary words — the menu says "Users", not "Identities".** The model uses + domain vocabulary; labels use the reader's, per Nielsen's heuristic #2 (match between system and + the real world). This is not a rename of an Ory concept: Ory's own docs state it uses "identity" + *interchangeably* with "users"/"accounts". Same split as `chrome.user`/`ShellUser` (the avatar + view-model) versus `SessionIdentity`/`ctx.identity` (the entity). - **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs can race (one job's logout can 401 another's push — recover by re-running), diff --git a/todo.md b/todo.md index 4fe60d7..df3f1bb 100644 --- a/todo.md +++ b/todo.md @@ -15,9 +15,9 @@ - [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.) - [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.) - [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) -- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & roles](README.md#users-groups--roles) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `role:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.) +- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Identities, groups & permissions](README.md#identities-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.) - [ ] Add i18n support. -- [x] Follow Kratos and rename the Keto `User` namespace to `Identity`. (OPL `class Identity`, subjects `identity:`, and the session type `User` → `SessionIdentity` with `ctx.user` → `ctx.identity`. No migration was needed after all: `keto-migrate` runs Keto's *own* bundled schema migrations and our tuples are runtime data written by `bootstrap.ts` and the admin plugin — with zero installations, `docker compose down -v` is the whole story. The name collided with the existing `Identity` DTO that `#plugin-api` re-exports from `kratos-admin.ts` — that one is the full Kratos record (traits, state, addresses) and kept the plain name; ours is the JWT projection `{ id, email, roles }`, hence `SessionIdentity`. The presentation layer deliberately still says "user" — `ShellUser`, `chrome.user`, the EJS `user` locals — because that is the avatar/profile view-model, not the identity entity.) +- [x] Follow Kratos and rename the Keto `User` namespace to `Identity`. (OPL `class Identity`, subjects `identity:`, and the session type `User` → `SessionIdentity` with `ctx.user` → `ctx.identity`. No migration was needed after all: `keto-migrate` runs Keto's *own* bundled schema migrations and our tuples are runtime data written by `bootstrap.ts` and the admin plugin — with zero installations, `docker compose down -v` is the whole story. The name collided with the existing `Identity` DTO that `#plugin-api` re-exports from `kratos-admin.ts` — that one is the full Kratos record (traits, state, addresses) and kept the plain name; ours is the JWT projection `{ id, email, permissions }`, hence `SessionIdentity`. The presentation layer deliberately still says "user" — `ShellUser`, `chrome.user`, the EJS `user` locals — because that is the avatar/profile view-model, not the identity entity.) - [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one. - [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `.` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer. @@ -27,8 +27,8 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API, - [x] **HIGH — Split `handleRequest` in `src/http/app.ts` (~380 lines).** It mixes the request pipeline with inline implementations of ~10 built-in endpoints (Kratos flows, /oauth2/*, /auth/complete, /logout, /, /dashboard, 404/405). Extract each endpoint into a named handler (auth/OAuth2 group → `src/auth/` route module) with the same `(req, res, ctx)` shape plugin routes use; reduce `handleRequest` to pipeline → internal route table → `sendResult`. - [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote). -- [ ] **MEDIUM — De-duplicate `examples/plugins/admin/admin-groups.ts` and `admin-roles.ts` (~80% identical).** Same "Keto membership object admin" concept twice; extract a parameterized helper keyed on `{ namespace, base, labels, columns }`, leave roles' effective-access view as the only delta. Matters extra because this is the reference plugin people copy. -- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, roles, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block. +- [ ] **MEDIUM — De-duplicate `examples/plugins/admin/admin-groups.ts` and `admin-permissions.ts` (~80% identical).** Same "Keto membership object admin" concept twice; extract a parameterized helper keyed on `{ namespace, base, labels, columns }`, leave permissions' effective-access view as the only delta. Matters extra because this is the reference plugin people copy. +- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, permissions, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block. - [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive. - [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`. - [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision.