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 "github.com/carlosframework/rastrillo/csrf"
42 "github.com/carlosframework/rastrillo/db"
43 "github.com/carlosframework/rastrillo/flash"
44 "github.com/carlosframework/rastrillo/migrate"
45 "github.com/carlosframework/rastrillo/password" // or .../auth for keymail
46 "github.com/carlosframework/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
237Every mutation is one transaction with its invariant enforced inside it:
238
239```go
240rs.Claim(ctx, subject, email, name) // first arrival ⇒ Owner; zero ROWS, not zero active
241rs.Invite(ctx, actor, email, role) // plaintext token ONCE; SUPERSEDES any live one
242rs.Revoke(ctx, actor, invitationID)
243rs.Accept(ctx, token, subject, name) // compare-and-swap, never lookup-then-write
244rs.SetRole(ctx, actor, target, role)
245rs.Deactivate(ctx, actor, target) // removal is never a delete
246rs.Reactivate(ctx, actor, target) // the ONLY way back in
247rs.Transfer(ctx, owner, to) // demote + promote in one transaction
248rs.BySubject / ByID / Members / PendingInvitations / IsEmpty
249```
250
251Refusals: `errors.Is` against `ErrInvalid`(400), `ErrForbidden`(403),
252`ErrNotFound`(404), `ErrNoInvitation`, `ErrOwnerExists`; `ErrLastOwner`
253unwraps to `ErrForbidden`. Helpers: `idear.From(r) *Member` (the viewer,
254nil outside `Require`), `idear.Grantable(actor) []Role` (builds
255`MembersPage.Grantable`, or any selector an app builds itself),
256`idear.WithMember(r, m) *http.Request` (plant a viewer — tests, or an
257app resolving membership its own way), `idear.TokenFrom(r)` (§4).
258
259`Subject` is the join to the app's identity, a **`string` on every
260path** — never an `int64`, never `sessions.UserID`. Password: the
261decimal user id. Keymail: the verified address, lowercased.
262
263`Config.Subject`'s default reads that value; **an override must
264preserve its shape** (no `@` for password-like, `@` for keymail-like) —
265`addressOf`, behind Claim's display email and reconciliation's email
266match, decides "is this an address" by `strings.Contains(subject,
267"@")` alone, so an opaque address-shaped override is silently read as
268one.
269
270**The traps.** Each cost a review round.
271
272- **`CarryToken` is MANDATORY on the password path** — `Create` gets no
273 `*http.Request`; skip it and every invited signup is refused.
274- **`Require` outside a session group silently 404s everything**, Owner
275 included, indistinguishable from a real refusal — only the log says
276 otherwise.
277- **`RequireRole` stacks INSIDE `Require`, never bare** — bare it 403s
278 a stranger with no membership check. `Routes()` composes correctly;
279 don't re-wrap it.
280- **Deactivation is per-request, not at sign-in** — `password.Signin`
281 mints regardless; only `Require` refuses a removed member. **Gate
282 `/`** — an ungated landing page is where this leaks.
283- **`Config.NotFound` must be the SAME function value as chi's own.**
284 Two 404 pages that merely agree today is a membership oracle waiting
285 to drift, undetectable at runtime.
286- **One identity plugin per app** — both together gives one human two
287 subjects (a decimal id and an address), two roster rows nothing
288 reconciles.
289- **Set `Config.EmailForSubject` on the password path.** It makes
290 reconciliation require the invitation match the signed-in viewer.
291 Nil, and that route trusts **possession of the token alone** — a
292 leaked token (flash cookie, URL, logs) is enough. Keymail never
293 needs the hook.
294- **Under keymail the address IS the identity; idear never rebinds
295 it.** Deactivate on offboarding (`/members/{id}/remove`) or a
296 recycled address signs the new holder in as the old member. An
297 address change orphans a non-Owner (re-invite them) or, for the
298 **Owner**, needs §6's rebind — as does switching identity plugins,
299 which orphans the whole roster at once.
300- **`idear.Schema` merges into `BootSchema`, never `Schema`; models
301 never go in `Models`.** Mixed in, `check` is permanently red and
302 `generate` collides a second `CREATE TABLE idear_members`. Guard:
303 `migrate.Generate(ctx, Schema.All(), Models)` zero changes (§7).
304- **An Admin may grant only Member** — `checkInviteRole` refuses
305 anything not strictly below the poster's rank; build selectors from
306 `MembersPage.Grantable`, never the three constants.
307- **Set `Site` in production** — the default is the request's `Host`,
308 client-supplied, on a public unauthenticated page.
309- **Set `ClientKey` behind a proxy**, or every request shares the
310 proxy's address as one global bucket. idear never reads
311 `X-Forwarded-For` itself — unverifiable is spoofable.
312
313General discipline: never bind a form onto a struct (idear's own carry
314`Role`/`DeactivatedAt`) — read named fields from `PostForm`, not
315`Form`. `role` never comes from a form you build; `checkInviteRole`'s
316strictly-below rule is what makes idear's own reads safe. 404, never
317403, for a non-member. Allow-lists, not escaping, for an `ORDER BY` or
318a `style` attribute — derive test payloads from the list under test.
319Hiding a control is never the enforcement — the store refuses again
320inside its own transaction. `refusedCopy` (admit.go) is a constant for
321every refused address, never a format string — see that file for why.
322
323## 6. Owner break-glass
324
325A lost Owner credential means a permanently unadministrable instance:
326nobody may act on an Owner, `role=owner` is refused everywhere, and
327`Transfer` needs the Owner to run it. No API path out — the recovery is
328SQL, written down rather than improvised.
329
330**Stop the instance first** — SQLite has one writer, and the running app
331holds it.
332
333```sql
334-- Who is who.
335SELECT id, subject, email, role, deactivated_at FROM idear_members ORDER BY id;
336
337-- Move ownership to member 4. Both statements, or neither: the invariant is
338-- "exactly one active Owner", and half of this leaves zero or two.
339BEGIN;
340UPDATE idear_members SET role = 'admin' WHERE role = 'owner';
341UPDATE idear_members SET role = 'owner', deactivated_at = NULL WHERE id = 4;
342COMMIT;
343
344-- Verify before restarting. Must be exactly 1, and NULL.
345SELECT count(*), max(deactivated_at) FROM idear_members WHERE role = 'owner';
346```
347
348If only the *credential* is lost and the roster is fine, that's the app's
349own table, not idear's: under password, overwrite `users.password_hash`
350with a fresh `password.Hash(...)`; under keymail there's nothing to
351reset — the address is the credential.
352
353### Rebinding a subject
354
355The other break-glass, for §5's hazards: `subject` is the join to the
356app's identity and **idear never rewrites it**. A changed address (keymail)
357or a plugin switch makes a member a stranger with no API path back for an
358Owner. Rebinding is SQL too. **Stop the instance first**, same as above.
359
360```sql
361-- 1. Look before you write. BOTH rows matter: the one being moved, and any
362-- row the NEW subject already has — subject is UNIQUE, so a rebind onto
363-- a subject that already has one fails outright. If it does have one,
364-- decide which of the two survives BEFORE touching either: the loser's
365-- member id may be referenced by the app's own tables.
366SELECT id, subject, email, role, deactivated_at FROM idear_members
367 WHERE subject IN ('OLD-SUBJECT', 'NEW-SUBJECT');
368
369-- 2. Rebind. email moves with the subject, because under keymail the
370-- subject IS the address and a stale display cache misleads the members
371-- page. deactivated_at is cleared for the same reason it is cleared in
372-- the transfer above: a rebind onto a deactivated row hands the new
373-- subject a membership that 404s on every route, which reads exactly
374-- like the rebind not having worked. Drop that clause — deliberately —
375-- if the person is meant to stay removed. updated_at is left alone on
376-- purpose: it is a GORM timestamp, CURRENT_TIMESTAMP does not write
377-- GORM's format, and this schema already has one column whose
378-- comparison is a text comparison.
379BEGIN;
380UPDATE idear_members
381 SET subject = 'NEW-SUBJECT', email = 'NEW-EMAIL', deactivated_at = NULL
382 WHERE subject = 'OLD-SUBJECT';
383COMMIT;
384
385-- 3. Verify before restarting: exactly one row, at the role it had, with
386-- deactivated_at NULL. NOTHING here means the UPDATE matched nothing —
387-- check the OLD-SUBJECT spelling against step 1 rather than re-running.
388SELECT id, subject, email, role, deactivated_at FROM idear_members
389 WHERE subject = 'NEW-SUBJECT';
390```
391
392`NEW-SUBJECT` is spelled the way the identity plugin mints it — the one
393easy place to get wrong: **keymail**, the new address, lowercased and
394trimmed; **password**, the decimal `users.id` of the row they'll sign in
395as, not their address. Get it wrong and they sign in and 404 everywhere,
396§5's silent trap by another door.
397
398`example/app_test.go`'s `TestBreakGlassRebindsASubject` runs this exact
399block — read out of this file, only its three placeholders filled in —
400against a database built by the example's migrations, then signs in over
401real HTTP as the new subject and performs an Owner-only action with it.
402
403## 7. Testing
404
405Drive the mounted app over real HTTP, with a cookie jar and a same-origin
406`Origin` header — the path a browser and an attacker both take. A test
407calling a handler directly proves only the last layer of a stack whose
408whole job is the middle. `example/app_test.go` is the template.
409
410Cover at least:
411
412- a non-member is refused read **and** write on every route, with 404s
413 byte-identical to the app's own for a path that does not exist;
414- a Member is refused every management action; an Admin cannot change,
415 demote or deactivate an Admin or the Owner;
416- a posted `role=owner` never lands, on any path, for any actor;
417- an invited address cannot be claimed **without the token**;
418- a removed member still signs in and still gets 404 on `/`;
419- an invited signup that fails validation re-renders a form still carrying
420 the token, and the **second** attempt succeeds;
421- a cross-origin POST to an idear route is refused 403;
422- the app's own Schema and Models agree (`migrate.Generate` returning zero
423 changes is `rastrillo migration check` in test form) — which is also what
424 catches an idear model added to `Models`.
425
426## Checklist before you call a mount done
427
4281. `idear.Schema` is in `BootSchema`; no idear model is in `Models`.
4292. `POST /signup` is wrapped in `rs.CarryToken` (password path).
4303. `Config.NotFound` and chi's `NotFound` are the same function value.
4314. `Require` is mounted inside the session guard, and `/` is behind it.
4325. `RequireRole` appears only inside `Require`.
4336. `Site` is set; `ClientKey` is set if there is a proxy.
4347. The role selector is built from `Grantable`.
4358. One identity plugin, not two.
4369. `RenderSignup` seeds its hidden `invite` field from `idear.TokenFrom(r)`,
437 **and a test posts a failing signup to prove it** — this is the piece a
438 rewritten signup page loses silently.
43910. `csrf.Protect(origin)` is mounted app-wide, above every group, so it
440 covers idear's routes as well as yours.
44111. `EmailForSubject` is set on the password path, or you have decided,
442 knowingly, that a signed-in orphan may spend any token they hold.
443