rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1---
2name: idear
3description: Add roles and membership to a Rastrillo app: Owner/Admin/Member, invitations, the membership gate.
4---
5
6# idear
7
8The roster for a Rastrillo instance: who is in it, at what rank, and who
9may change that. This file is the authoring doc — read it instead of the
10source. Module `amadan.net/rastrillo/idear`; `example/` is the worked
11reference, a complete app whose `app_test.go` drives the whole flow
12through real HTTP.
13
14idear is an **addon, not core**: Rastrillo has no role concept, and idear
15never mints a session, hashes a password, or renders a sign-in form — it
16sits on `sessions` and whichever identity plugin the app already chose.
17Nor does it do tenancy: a CARLOS app serves **one team per instance**, and
18separating teams is the platform's process-and-file boundary, never a
19`WHERE` clause. idear decides who may do what **inside** one instance.
20
21Read Rastrillo's own `SKILL.md` first; everything here assumes it.
22
23## 1. Install
24
25```sh
26go get amadan.net/rastrillo/idear
27```
28
29**No `replace` directive.** idear is a published module fetched by path, not
30a local chassis you point at — a `replace` here pins the whole team to one
31checkout and is never right.
32
33## 2. Wire it
34
35Everything below comes from Rastrillo's own packages plus this one:
36
37```go
38import (
39 "amadan.net/rastrillo/idear"
40
41 "amadan.net/rastrillo/rastrillo/csrf"
42 "amadan.net/rastrillo/rastrillo/db"
43 "amadan.net/rastrillo/rastrillo/flash"
44 "amadan.net/rastrillo/rastrillo/migrate"
45 "amadan.net/rastrillo/rastrillo/password" // or .../auth for keymail
46 "amadan.net/rastrillo/rastrillo/sessions"
47 "github.com/go-chi/chi/v5"
48)
49```
50
51Even a **keymail-only** app links `rastrillo/password`: idear's own
52refusal sentinel (`password.Refuse`, §5) comes from there regardless of
53which plugin the app mounts — an import, not a call. Splitting it out
54so a keymail-only app could drop the dependency is a v2 idea, not v1.
55
56Five things, in this order. Every step is load-bearing; `example/app.go` is
57this same list with the reasons attached.
58
59**1. Schema.** `idear.Schema` merges into **`BootSchema`**, never into the
60app's own `Schema` — and `BootSchema` is what gets applied at boot:
61
62```go
63var BootSchema = migrate.Merge(sessions.Schema, idear.Schema, Schema)
64
65// in App(), before anything else runs:
66if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
67 return nil, err
68}
69```
70
71idear's models **never** go in the app's `Models` list. Nothing stops you
72putting them there — idear exports the *types* but not the *list*, so
73`[]any{&Note{}, &idear.Member{}}` compiles — and only a test catches it
74(§5, §7).
75
76**2. The roster**, once per process:
77
78```go
79rs, err := idear.New(idear.Config{
80 DB: d.G, // required
81 OpenSignUp: false, // true admits any verified address at Member
82 InviteTTL: 0, // default 7 days
83 NotFound: notFound, // THE SAME func value chi's NotFound gets (§5)
84 Forbidden: forbidden, // 403 for a member who may not act
85 Logger: logger,
86 // Subject defaults to sessions.Current(r).Subject — correct for both
87 // shipped identity plugins. Override only if the viewer arrives some
88 // other way.
89 EmailForSubject: emailForSubject(d.G), // SET IT on the password path (§5)
90})
91```
92
93**3. The identity adapter** — one of two, never both (§5):
94
95```go
96// password: wrap the app's own user-creating function.
97ph, err := password.New(password.Config{
98 Sessions: sess, Lookup: lookupUser(d.G),
99 Create: rs.Admitting(createUser(d.G)),
100 RenderSignin: renderSignin, RenderSignup: renderSignup,
101})
102
103// keymail (rastrillo/auth): answer "may this verified address have a session?"
104ah, err := auth.New(auth.Config{Sessions: sess, Authorize: rs.Authorize})
105```
106
107**4. The handlers:**
108
109```go
110hs, err := idear.NewHandlers(idear.HandlerConfig{
111 Roster: rs, // required
112 RenderMembers: renderMembers, // required
113 RenderInvitation: renderInvitation, // required
114 Site: "Acme's board", // SET IT (§5)
115 MembersPath: "/members", // where mutations 303 to
116 InvitationPath: "/invitations/", // link prefix: path + token
117 Deliver: mailInvitation, // nil flashes the link instead
118 RateLimit: idear.RateLimit{}, // zero value is the default
119 ClientKey: nil, // SET IT behind a proxy (§5)
120})
121```
122
123**5. The mount:**
124
125```go
126r := chi.NewRouter()
127r.Use(csrf.Protect(origin))
128r.Use(sess.Middleware)
129r.NotFound(notFound) // the same func value as above
130
131r.Get("/signin", ph.SigninPage); r.Post("/signin", ph.Signin)
132r.Get("/signup", ph.SignupPage)
133// MANDATORY on the password path — see §5.
134r.Method(http.MethodPost, "/signup", rs.CarryToken(http.HandlerFunc(ph.Signup)))
135r.Post("/signout", ph.Signout)
136
137for _, rt := range hs.Routes() { // the two public invitation routes
138 if rt.Public { r.Method(rt.Method, rt.Pattern, rt.Handler) }
139}
140
141r.Group(func(gr chi.Router) {
142 gr.Use(sess.Require) // the app's session guard
143 for _, rt := range hs.Routes() { // already Require+RequireRole wrapped
144 if !rt.Public { gr.Method(rt.Method, rt.Pattern, rt.Handler) }
145 }
146 gr.Group(func(mr chi.Router) {
147 mr.Use(rs.Require) // the membership gate
148 mr.Get("/", board) // "/" IS BEHIND IT — see §5
149 mr.With(rs.RequireRole(idear.RoleAdmin)).Post("/things/{id}/delete", del)
150 })
151})
152```
153
154`rs.Require` stashes the viewer; read it with `idear.From(r)` (`*Member`,
155nil outside Require). It **never redirects** — signed-out is the session
156guard's business — and answers a non-member and a deactivated member
157identically, via `Config.NotFound`.
158
159## 3. The route table
160
161`hs.Routes()` returns them already wrapped in the middleware each needs, in
162the right order, with the rank it enforces. Paths are the app's; change them
163and set `MembersPath`/`InvitationPath` to match.
164
165```
166GET /members Member
167POST /members/invitations Admin
168POST /members/invitations/{id}/revoke Admin
169POST /members/{id}/role Admin
170POST /members/{id}/remove Admin (deactivate, never delete)
171POST /members/{id}/restore Admin
172POST /members/transfer Owner (the only path to Owner)
173GET /invitations/{token} public (rate-limited)
174POST /invitations/{token} public (rate-limited; reconciliation)
175```
176
177**Both** public routes are rate-limited, not just the lookup: one reads a
178secret and the other spends it, and the limiter is not optional — `RateLimit`
179can only be widened, never switched off.
180
181`POST /invitations/{token}` is the **reconciliation** route, not
182decoration. Admission cannot be one transaction — `Admitting` calls the
183app's opaque `Create`, then writes the Member — so a failure between
184them (or a lost first-signup claim race) leaves a user row with no
185membership: an orphan who signs in and 404s everywhere until they open
186their invitation link while signed in.
187
188What that route then asks of them depends on whether idear can learn
189their address. Under keymail the Subject *is* the address. Under
190password it's an opaque user id, so only the app can resolve it: set
191**`Config.EmailForSubject`** and reconciliation applies admission's own
192email match. Nil, and **possession of the token is the whole credential
193there** — see §5.
194
195## 4. Rendering
196
197Two callbacks, following `password.Config.RenderSignin`. **Neither may
198write a status** — idear always writes 400/403/404/500 first, and a
199renderer's own `WriteHeader` is a logged no-op — **and neither may call
200`flash.Take`**: the members page has already taken it and handed it back
201as `Notice`/`Error` (a second `Take` shows the notice twice), and the
202invitation page has no idear flash at all (a `Take` there eats an
203unrelated notice the visitor was owed). Render what idear hands you.
204
205- `MembersPage{Viewer, Members, Invitations, Grantable, Error, Notice}` —
206 build the role selector from **`Grantable`**, never the three constants
207 (§5).
208- `InvitationPage{Role, Site, Token, Error, SignedIn, Reconcile}` — **no
209 address field**: the public GET is an unauthenticated secret lookup and
210 must not echo who was invited. Show accept when `Reconcile`, else a
211 signup form with `<input type="hidden" name="invite" value="{{.Token}}">`.
212
213`password.PageData` has nowhere to carry a token, so a signup that fails
214validation re-renders a form whose hidden field comes back **empty**, and
215the *second* attempt is refused for holding none — "invited people can
216never join," one step later than the mistake. `idear.TokenFrom(r)` hands
217back what `CarryToken` lifted off that POST:
218
219```go
220func renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) {
221 render(w, r, "signup", signupView{PageData: d, Invite: idear.TokenFrom(r)})
222}
223```
224
225It reads only what `CarryToken` stashed — never the query string, never
226the body directly — so a missing `CarryToken` stays loud rather than
227papered over. Write the test for this; see §7.
228
229## 5. Roles, the store, and the traps
230
231`Owner > Admin > Member`, one Owner always. `Role` is a string; parse
232outside input with `ParseRole`. `MayActOn(actor, target)` — full rules
233in `policy.go` — backs every mutation: actor active and at least Admin,
234never the target, target strictly below actor's rank; nobody acts on
235an Owner.
236
237**Peer grants** are a build-time switch: `go build -tags idear_peers`
238widens that last rule to "at or below", for granting and managing
239alike — an Admin may then invite, promote, demote, remove and restore
240other Admins. The Owner is untouched either way: never granted (only
241`Transfer`), never acted on. `idear.PeerGrants` reports which build
242you are in. Every binary and every `go test` must carry the same tag,
243or the app runs one policy and tests another.
244
245Every mutation is one transaction with its invariant enforced inside it:
246
247```go
248rs.Claim(ctx, subject, email, name) // first arrival ⇒ Owner; zero ROWS, not zero active
249rs.Invite(ctx, actor, email, role) // plaintext token ONCE; SUPERSEDES any live one
250rs.Revoke(ctx, actor, invitationID)
251rs.Accept(ctx, token, subject, name) // compare-and-swap, never lookup-then-write
252rs.SetRole(ctx, actor, target, role)
253rs.Deactivate(ctx, actor, target) // removal is never a delete
254rs.Reactivate(ctx, actor, target) // the ONLY way back in
255rs.Transfer(ctx, owner, to) // demote + promote in one transaction
256rs.BySubject / ByID / Members / PendingInvitations / IsEmpty
257```
258
259Refusals: `errors.Is` against `ErrInvalid`(400), `ErrForbidden`(403),
260`ErrNotFound`(404), `ErrNoInvitation`, `ErrOwnerExists`; `ErrLastOwner`
261unwraps to `ErrForbidden`. Helpers: `idear.From(r) *Member` (the viewer,
262nil outside `Require`), `idear.Grantable(actor) []Role` (builds
263`MembersPage.Grantable`, or any selector an app builds itself),
264`idear.WithMember(r, m) *http.Request` (plant a viewer — tests, or an
265app resolving membership its own way), `idear.TokenFrom(r)` (§4).
266
267`Subject` is the join to the app's identity, a **`string` on every
268path** — never an `int64`, never `sessions.UserID`. Password: the
269decimal user id. Keymail: the verified address, lowercased.
270
271`Config.Subject`'s default reads that value; **an override must
272preserve its shape** (no `@` for password-like, `@` for keymail-like) —
273`addressOf`, behind Claim's display email and reconciliation's email
274match, decides "is this an address" by `strings.Contains(subject,
275"@")` alone, so an opaque address-shaped override is silently read as
276one.
277
278**The traps.** Each cost a review round.
279
280- **`CarryToken` is MANDATORY on the password path** — `Create` gets no
281 `*http.Request`; skip it and every invited signup is refused.
282- **`Require` outside a session group silently 404s everything**, Owner
283 included, indistinguishable from a real refusal — only the log says
284 otherwise.
285- **`RequireRole` stacks INSIDE `Require`, never bare** — bare it 403s
286 a stranger with no membership check. `Routes()` composes correctly;
287 don't re-wrap it.
288- **Deactivation is per-request, not at sign-in** — `password.Signin`
289 mints regardless; only `Require` refuses a removed member. **Gate
290 `/`** — an ungated landing page is where this leaks.
291- **`Config.NotFound` must be the SAME function value as chi's own.**
292 Two 404 pages that merely agree today is a membership oracle waiting
293 to drift, undetectable at runtime.
294- **One identity plugin per app** — both together gives one human two
295 subjects (a decimal id and an address), two roster rows nothing
296 reconciles.
297- **Set `Config.EmailForSubject` on the password path.** It makes
298 reconciliation require the invitation match the signed-in viewer.
299 Nil, and that route trusts **possession of the token alone** — a
300 leaked token (flash cookie, URL, logs) is enough. Keymail never
301 needs the hook.
302- **Under keymail the address IS the identity; idear never rebinds
303 it.** Deactivate on offboarding (`/members/{id}/remove`) or a
304 recycled address signs the new holder in as the old member. An
305 address change orphans a non-Owner (re-invite them) or, for the
306 **Owner**, needs §6's rebind — as does switching identity plugins,
307 which orphans the whole roster at once.
308- **`idear.Schema` merges into `BootSchema`, never `Schema`; models
309 never go in `Models`.** Mixed in, `check` is permanently red and
310 `generate` collides a second `CREATE TABLE idear_members`. Guard:
311 `migrate.Generate(ctx, Schema.All(), Models)` zero changes (§7).
312- **An Admin may grant only Member** (Admin too under `idear_peers`) —
313 `checkInviteRole` refuses anything beyond the poster's reach; build
314 selectors from `MembersPage.Grantable`, never the three constants.
315- **Set `Site` in production** — the default is the request's `Host`,
316 client-supplied, on a public unauthenticated page.
317- **Set `ClientKey` behind a proxy**, or every request shares the
318 proxy's address as one global bucket. idear never reads
319 `X-Forwarded-For` itself — unverifiable is spoofable.
320
321General discipline: never bind a form onto a struct (idear's own carry
322`Role`/`DeactivatedAt`) — read named fields from `PostForm`, not
323`Form`. `role` never comes from a form you build; `checkInviteRole`'s
324reach rule is what makes idear's own reads safe. 404, never
325403, for a non-member. Allow-lists, not escaping, for an `ORDER BY` or
326a `style` attribute — derive test payloads from the list under test.
327Hiding a control is never the enforcement — the store refuses again
328inside its own transaction. `refusedCopy` (admit.go) is a constant for
329every refused address, never a format string — see that file for why.
330
331## 6. Owner break-glass
332
333A lost Owner credential means a permanently unadministrable instance:
334nobody may act on an Owner, `role=owner` is refused everywhere, and
335`Transfer` needs the Owner to run it. No API path out — the recovery is
336SQL, written down rather than improvised.
337
338**Stop the instance first** — SQLite has one writer, and the running app
339holds it.
340
341```sql
342-- Who is who.
343SELECT id, subject, email, role, deactivated_at FROM idear_members ORDER BY id;
344
345-- Move ownership to member 4. Both statements, or neither: the invariant is
346-- "exactly one active Owner", and half of this leaves zero or two.
347BEGIN;
348UPDATE idear_members SET role = 'admin' WHERE role = 'owner';
349UPDATE idear_members SET role = 'owner', deactivated_at = NULL WHERE id = 4;
350COMMIT;
351
352-- Verify before restarting. Must be exactly 1, and NULL.
353SELECT count(*), max(deactivated_at) FROM idear_members WHERE role = 'owner';
354```
355
356If only the *credential* is lost and the roster is fine, that's the app's
357own table, not idear's: under password, overwrite `users.password_hash`
358with a fresh `password.Hash(...)`; under keymail there's nothing to
359reset — the address is the credential.
360
361### Rebinding a subject
362
363The other break-glass, for §5's hazards: `subject` is the join to the
364app's identity and **idear never rewrites it**. A changed address (keymail)
365or a plugin switch makes a member a stranger with no API path back for an
366Owner. Rebinding is SQL too. **Stop the instance first**, same as above.
367
368```sql
369-- 1. Look before you write. BOTH rows matter: the one being moved, and any
370-- row the NEW subject already has — subject is UNIQUE, so a rebind onto
371-- a subject that already has one fails outright. If it does have one,
372-- decide which of the two survives BEFORE touching either: the loser's
373-- member id may be referenced by the app's own tables.
374SELECT id, subject, email, role, deactivated_at FROM idear_members
375 WHERE subject IN ('OLD-SUBJECT', 'NEW-SUBJECT');
376
377-- 2. Rebind. email moves with the subject, because under keymail the
378-- subject IS the address and a stale display cache misleads the members
379-- page. deactivated_at is cleared for the same reason it is cleared in
380-- the transfer above: a rebind onto a deactivated row hands the new
381-- subject a membership that 404s on every route, which reads exactly
382-- like the rebind not having worked. Drop that clause — deliberately —
383-- if the person is meant to stay removed. updated_at is left alone on
384-- purpose: it is a GORM timestamp, CURRENT_TIMESTAMP does not write
385-- GORM's format, and this schema already has one column whose
386-- comparison is a text comparison.
387BEGIN;
388UPDATE idear_members
389 SET subject = 'NEW-SUBJECT', email = 'NEW-EMAIL', deactivated_at = NULL
390 WHERE subject = 'OLD-SUBJECT';
391COMMIT;
392
393-- 3. Verify before restarting: exactly one row, at the role it had, with
394-- deactivated_at NULL. NOTHING here means the UPDATE matched nothing —
395-- check the OLD-SUBJECT spelling against step 1 rather than re-running.
396SELECT id, subject, email, role, deactivated_at FROM idear_members
397 WHERE subject = 'NEW-SUBJECT';
398```
399
400`NEW-SUBJECT` is spelled the way the identity plugin mints it — the one
401easy place to get wrong: **keymail**, the new address, lowercased and
402trimmed; **password**, the decimal `users.id` of the row they'll sign in
403as, not their address. Get it wrong and they sign in and 404 everywhere,
404§5's silent trap by another door.
405
406`example/app_test.go`'s `TestBreakGlassRebindsASubject` runs this exact
407block — read out of this file, only its three placeholders filled in —
408against a database built by the example's migrations, then signs in over
409real HTTP as the new subject and performs an Owner-only action with it.
410
411## 7. Testing
412
413Drive the mounted app over real HTTP, with a cookie jar and a same-origin
414`Origin` header — the path a browser and an attacker both take. A test
415calling a handler directly proves only the last layer of a stack whose
416whole job is the middle. `example/app_test.go` is the template.
417
418Cover at least:
419
420- a non-member is refused read **and** write on every route, with 404s
421 byte-identical to the app's own for a path that does not exist;
422- a Member is refused every management action; an Admin cannot change,
423 demote or deactivate an Admin or the Owner;
424- a posted `role=owner` never lands, on any path, for any actor;
425- an invited address cannot be claimed **without the token**;
426- a removed member still signs in and still gets 404 on `/`;
427- an invited signup that fails validation re-renders a form still carrying
428 the token, and the **second** attempt succeeds;
429- a cross-origin POST to an idear route is refused 403;
430- the app's own Schema and Models agree (`migrate.Generate` returning zero
431 changes is `rastrillo migration check` in test form) — which is also what
432 catches an idear model added to `Models`.
433
434## Checklist before you call a mount done
435
4361. `idear.Schema` is in `BootSchema`; no idear model is in `Models`.
4372. `POST /signup` is wrapped in `rs.CarryToken` (password path).
4383. `Config.NotFound` and chi's `NotFound` are the same function value.
4394. `Require` is mounted inside the session guard, and `/` is behind it.
4405. `RequireRole` appears only inside `Require`.
4416. `Site` is set; `ClientKey` is set if there is a proxy.
4427. The role selector is built from `Grantable`.
4438. One identity plugin, not two.
4449. `RenderSignup` seeds its hidden `invite` field from `idear.TokenFrom(r)`,
445 **and a test posts a failing signup to prove it** — this is the piece a
446 rewritten signup page loses silently.
44710. `csrf.Protect(origin)` is mounted app-wide, above every group, so it
448 covers idear's routes as well as yours.
44911. `EmailForSubject` is set on the password path, or you have decided,
450 knowingly, that a signed-in orphan may spend any token they hold.
451