rastrillo / idear Public

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

Plain git — no account needed to clone.

Download

Download this file

--- name: idear description: Add roles and membership to a Rastrillo app: Owner/Admin/Member, invitations, the membership gate. ---

idear

The roster for a Rastrillo instance: who is in it, at what rank, and who may change that. This file is the authoring doc — read it instead of the source. Module amadan.net/rastrillo/idear; example/ is the worked reference, a complete app whose app_test.go drives the whole flow through real HTTP.

idear is an addon, not core: Rastrillo has no role concept, and idear never mints a session, hashes a password, or renders a sign-in form — it sits on sessions and whichever identity plugin the app already chose. Nor does it do tenancy: a CARLOS app serves one team per instance, and separating teams is the platform's process-and-file boundary, never a WHERE clause. idear decides who may do what inside one instance.

Read Rastrillo's own SKILL.md first; everything here assumes it.

1. Install

go get amadan.net/rastrillo/idear

No replace directive. idear is a published module fetched by path, not a local chassis you point at — a replace here pins the whole team to one checkout and is never right.

2. Wire it

Everything below comes from Rastrillo's own packages plus this one:

import (
	"amadan.net/rastrillo/idear"

	"github.com/carlosframework/rastrillo/csrf"
	"github.com/carlosframework/rastrillo/db"
	"github.com/carlosframework/rastrillo/flash"
	"github.com/carlosframework/rastrillo/migrate"
	"github.com/carlosframework/rastrillo/password" // or .../auth for keymail
	"github.com/carlosframework/rastrillo/sessions"
	"github.com/go-chi/chi/v5"
)

Even a keymail-only app links rastrillo/password: idear's own refusal sentinel (password.Refuse, §5) comes from there regardless of which plugin the app mounts — an import, not a call. Splitting it out so a keymail-only app could drop the dependency is a v2 idea, not v1.

Five things, in this order. Every step is load-bearing; example/app.go is this same list with the reasons attached.

1. Schema. idear.Schema merges into BootSchema, never into the app's own Schema — and BootSchema is what gets applied at boot:

var BootSchema = migrate.Merge(sessions.Schema, idear.Schema, Schema)

// in App(), before anything else runs:
if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
	return nil, err
}

idear's models never go in the app's Models list. Nothing stops you putting them there — idear exports the types but not the list, so []any{&Note{}, &idear.Member{}} compiles — and only a test catches it (§5, §7).

2. The roster, once per process:

rs, err := idear.New(idear.Config{
	DB:         d.G,          // required
	OpenSignUp: false,        // true admits any verified address at Member
	InviteTTL:  0,            // default 7 days
	NotFound:   notFound,     // THE SAME func value chi's NotFound gets (§5)
	Forbidden:  forbidden,    // 403 for a member who may not act
	Logger:     logger,
	// Subject defaults to sessions.Current(r).Subject — correct for both
	// shipped identity plugins. Override only if the viewer arrives some
	// other way.
	EmailForSubject: emailForSubject(d.G), // SET IT on the password path (§5)
})

3. The identity adapter — one of two, never both (§5):

// password: wrap the app's own user-creating function.
ph, err := password.New(password.Config{
	Sessions: sess, Lookup: lookupUser(d.G),
	Create:   rs.Admitting(createUser(d.G)),
	RenderSignin: renderSignin, RenderSignup: renderSignup,
})

// keymail (rastrillo/auth): answer "may this verified address have a session?"
ah, err := auth.New(auth.Config{Sessions: sess, Authorize: rs.Authorize})

4. The handlers:

hs, err := idear.NewHandlers(idear.HandlerConfig{
	Roster:           rs,                 // required
	RenderMembers:    renderMembers,      // required
	RenderInvitation: renderInvitation,   // required
	Site:             "Acme's board",     // SET IT (§5)
	MembersPath:      "/members",         // where mutations 303 to
	InvitationPath:   "/invitations/",    // link prefix: path + token
	Deliver:          mailInvitation,     // nil flashes the link instead
	RateLimit:        idear.RateLimit{},  // zero value is the default
	ClientKey:        nil,                // SET IT behind a proxy (§5)
})

5. The mount:

r := chi.NewRouter()
r.Use(csrf.Protect(origin))
r.Use(sess.Middleware)
r.NotFound(notFound)                     // the same func value as above

r.Get("/signin", ph.SigninPage); r.Post("/signin", ph.Signin)
r.Get("/signup", ph.SignupPage)
// MANDATORY on the password path — see §5.
r.Method(http.MethodPost, "/signup", rs.CarryToken(http.HandlerFunc(ph.Signup)))
r.Post("/signout", ph.Signout)

for _, rt := range hs.Routes() {         // the two public invitation routes
	if rt.Public { r.Method(rt.Method, rt.Pattern, rt.Handler) }
}

r.Group(func(gr chi.Router) {
	gr.Use(sess.Require)                 // the app's session guard
	for _, rt := range hs.Routes() {     // already Require+RequireRole wrapped
		if !rt.Public { gr.Method(rt.Method, rt.Pattern, rt.Handler) }
	}
	gr.Group(func(mr chi.Router) {
		mr.Use(rs.Require)               // the membership gate
		mr.Get("/", board)               // "/" IS BEHIND IT — see §5
		mr.With(rs.RequireRole(idear.RoleAdmin)).Post("/things/{id}/delete", del)
	})
})

rs.Require stashes the viewer; read it with idear.From(r) (*Member, nil outside Require). It never redirects — signed-out is the session guard's business — and answers a non-member and a deactivated member identically, via Config.NotFound.

3. The route table

hs.Routes() returns them already wrapped in the middleware each needs, in the right order, with the rank it enforces. Paths are the app's; change them and set MembersPath/InvitationPath to match.

GET  /members                           Member
POST /members/invitations               Admin
POST /members/invitations/{id}/revoke   Admin
POST /members/{id}/role                 Admin
POST /members/{id}/remove               Admin   (deactivate, never delete)
POST /members/{id}/restore              Admin
POST /members/transfer                  Owner   (the only path to Owner)
GET  /invitations/{token}               public  (rate-limited)
POST /invitations/{token}               public  (rate-limited; reconciliation)

Both public routes are rate-limited, not just the lookup: one reads a secret and the other spends it, and the limiter is not optional — RateLimit can only be widened, never switched off.

POST /invitations/{token} is the reconciliation route, not decoration. Admission cannot be one transaction — Admitting calls the app's opaque Create, then writes the Member — so a failure between them (or a lost first-signup claim race) leaves a user row with no membership: an orphan who signs in and 404s everywhere until they open their invitation link while signed in.

What that route then asks of them depends on whether idear can learn their address. Under keymail the Subject is the address. Under password it's an opaque user id, so only the app can resolve it: set Config.EmailForSubject and reconciliation applies admission's own email match. Nil, and possession of the token is the whole credential there — see §5.

4. Rendering

Two callbacks, following password.Config.RenderSignin. Neither may write a status — idear always writes 400/403/404/500 first, and a renderer's own WriteHeader is a logged no-op — and neither may call flash.Take: the members page has already taken it and handed it back as Notice/Error (a second Take shows the notice twice), and the invitation page has no idear flash at all (a Take there eats an unrelated notice the visitor was owed). Render what idear hands you.

  • MembersPage{Viewer, Members, Invitations, Grantable, Error, Notice} — build the role selector from Grantable, never the three constants (§5).
  • InvitationPage{Role, Site, Token, Error, SignedIn, Reconcile}no address field: the public GET is an unauthenticated secret lookup and must not echo who was invited. Show accept when Reconcile, else a signup form with <input type="hidden" name="invite" value="{{.Token}}">.

password.PageData has nowhere to carry a token, so a signup that fails validation re-renders a form whose hidden field comes back empty, and the second attempt is refused for holding none — "invited people can never join," one step later than the mistake. idear.TokenFrom(r) hands back what CarryToken lifted off that POST:

func renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) {
	render(w, r, "signup", signupView{PageData: d, Invite: idear.TokenFrom(r)})
}

It reads only what CarryToken stashed — never the query string, never the body directly — so a missing CarryToken stays loud rather than papered over. Write the test for this; see §7.

5. Roles, the store, and the traps

Owner > Admin > Member, one Owner always. Role is a string; parse outside input with ParseRole. MayActOn(actor, target) — full rules in policy.go — backs every mutation: actor active and at least Admin, never the target, target strictly below actor's rank; nobody acts on an Owner.

Every mutation is one transaction with its invariant enforced inside it:

rs.Claim(ctx, subject, email, name)          // first arrival ⇒ Owner; zero ROWS, not zero active
rs.Invite(ctx, actor, email, role)           // plaintext token ONCE; SUPERSEDES any live one
rs.Revoke(ctx, actor, invitationID)
rs.Accept(ctx, token, subject, name)         // compare-and-swap, never lookup-then-write
rs.SetRole(ctx, actor, target, role)
rs.Deactivate(ctx, actor, target)            // removal is never a delete
rs.Reactivate(ctx, actor, target)            // the ONLY way back in
rs.Transfer(ctx, owner, to)                  // demote + promote in one transaction
rs.BySubject / ByID / Members / PendingInvitations / IsEmpty

Refusals: errors.Is against ErrInvalid(400), ErrForbidden(403), ErrNotFound(404), ErrNoInvitation, ErrOwnerExists; ErrLastOwner unwraps to ErrForbidden. Helpers: idear.From(r) *Member (the viewer, nil outside Require), idear.Grantable(actor) []Role (builds MembersPage.Grantable, or any selector an app builds itself), idear.WithMember(r, m) *http.Request (plant a viewer — tests, or an app resolving membership its own way), idear.TokenFrom(r) (§4).

Subject is the join to the app's identity, a string on every path — never an int64, never sessions.UserID. Password: the decimal user id. Keymail: the verified address, lowercased.

Config.Subject's default reads that value; an override must preserve its shape (no @ for password-like, @ for keymail-like) — addressOf, behind Claim's display email and reconciliation's email match, decides "is this an address" by strings.Contains(subject, "@") alone, so an opaque address-shaped override is silently read as one.

The traps. Each cost a review round.

  • CarryToken is MANDATORY on the password pathCreate gets no *http.Request; skip it and every invited signup is refused.
  • Require outside a session group silently 404s everything, Owner included, indistinguishable from a real refusal — only the log says otherwise.
  • RequireRole stacks INSIDE Require, never bare — bare it 403s a stranger with no membership check. Routes() composes correctly; don't re-wrap it.
  • Deactivation is per-request, not at sign-inpassword.Signin mints regardless; only Require refuses a removed member. Gate / — an ungated landing page is where this leaks.
  • Config.NotFound must be the SAME function value as chi's own. Two 404 pages that merely agree today is a membership oracle waiting to drift, undetectable at runtime.
  • One identity plugin per app — both together gives one human two subjects (a decimal id and an address), two roster rows nothing reconciles.
  • Set Config.EmailForSubject on the password path. It makes reconciliation require the invitation match the signed-in viewer. Nil, and that route trusts possession of the token alone — a leaked token (flash cookie, URL, logs) is enough. Keymail never needs the hook.
  • Under keymail the address IS the identity; idear never rebinds it. Deactivate on offboarding (/members/{id}/remove) or a recycled address signs the new holder in as the old member. An address change orphans a non-Owner (re-invite them) or, for the Owner, needs §6's rebind — as does switching identity plugins, which orphans the whole roster at once.
  • idear.Schema merges into BootSchema, never Schema; models never go in Models. Mixed in, check is permanently red and generate collides a second CREATE TABLE idear_members. Guard: migrate.Generate(ctx, Schema.All(), Models) zero changes (§7).
  • An Admin may grant only MembercheckInviteRole refuses anything not strictly below the poster's rank; build selectors from MembersPage.Grantable, never the three constants.
  • Set Site in production — the default is the request's Host, client-supplied, on a public unauthenticated page.
  • Set ClientKey behind a proxy, or every request shares the proxy's address as one global bucket. idear never reads X-Forwarded-For itself — unverifiable is spoofable.

General discipline: never bind a form onto a struct (idear's own carry Role/DeactivatedAt) — read named fields from PostForm, not Form. role never comes from a form you build; checkInviteRole's strictly-below rule is what makes idear's own reads safe. 404, never 403, for a non-member. Allow-lists, not escaping, for an ORDER BY or a style attribute — derive test payloads from the list under test. Hiding a control is never the enforcement — the store refuses again inside its own transaction. refusedCopy (admit.go) is a constant for every refused address, never a format string — see that file for why.

6. Owner break-glass

A lost Owner credential means a permanently unadministrable instance: nobody may act on an Owner, role=owner is refused everywhere, and Transfer needs the Owner to run it. No API path out — the recovery is SQL, written down rather than improvised.

Stop the instance first — SQLite has one writer, and the running app holds it.

-- Who is who.
SELECT id, subject, email, role, deactivated_at FROM idear_members ORDER BY id;

-- Move ownership to member 4. Both statements, or neither: the invariant is
-- "exactly one active Owner", and half of this leaves zero or two.
BEGIN;
UPDATE idear_members SET role = 'admin' WHERE role = 'owner';
UPDATE idear_members SET role = 'owner', deactivated_at = NULL WHERE id = 4;
COMMIT;

-- Verify before restarting. Must be exactly 1, and NULL.
SELECT count(*), max(deactivated_at) FROM idear_members WHERE role = 'owner';

If only the credential is lost and the roster is fine, that's the app's own table, not idear's: under password, overwrite users.password_hash with a fresh password.Hash(...); under keymail there's nothing to reset — the address is the credential.

Rebinding a subject

The other break-glass, for §5's hazards: subject is the join to the app's identity and idear never rewrites it. A changed address (keymail) or a plugin switch makes a member a stranger with no API path back for an Owner. Rebinding is SQL too. Stop the instance first, same as above.

-- 1. Look before you write. BOTH rows matter: the one being moved, and any
--    row the NEW subject already has — subject is UNIQUE, so a rebind onto
--    a subject that already has one fails outright. If it does have one,
--    decide which of the two survives BEFORE touching either: the loser's
--    member id may be referenced by the app's own tables.
SELECT id, subject, email, role, deactivated_at FROM idear_members
 WHERE subject IN ('OLD-SUBJECT', 'NEW-SUBJECT');

-- 2. Rebind. email moves with the subject, because under keymail the
--    subject IS the address and a stale display cache misleads the members
--    page. deactivated_at is cleared for the same reason it is cleared in
--    the transfer above: a rebind onto a deactivated row hands the new
--    subject a membership that 404s on every route, which reads exactly
--    like the rebind not having worked. Drop that clause — deliberately —
--    if the person is meant to stay removed. updated_at is left alone on
--    purpose: it is a GORM timestamp, CURRENT_TIMESTAMP does not write
--    GORM's format, and this schema already has one column whose
--    comparison is a text comparison.
BEGIN;
UPDATE idear_members
   SET subject = 'NEW-SUBJECT', email = 'NEW-EMAIL', deactivated_at = NULL
 WHERE subject = 'OLD-SUBJECT';
COMMIT;

-- 3. Verify before restarting: exactly one row, at the role it had, with
--    deactivated_at NULL. NOTHING here means the UPDATE matched nothing —
--    check the OLD-SUBJECT spelling against step 1 rather than re-running.
SELECT id, subject, email, role, deactivated_at FROM idear_members
 WHERE subject = 'NEW-SUBJECT';

NEW-SUBJECT is spelled the way the identity plugin mints it — the one easy place to get wrong: keymail, the new address, lowercased and trimmed; password, the decimal users.id of the row they'll sign in as, not their address. Get it wrong and they sign in and 404 everywhere, §5's silent trap by another door.

example/app_test.go's TestBreakGlassRebindsASubject runs this exact block — read out of this file, only its three placeholders filled in — against a database built by the example's migrations, then signs in over real HTTP as the new subject and performs an Owner-only action with it.

7. Testing

Drive the mounted app over real HTTP, with a cookie jar and a same-origin Origin header — the path a browser and an attacker both take. A test calling a handler directly proves only the last layer of a stack whose whole job is the middle. example/app_test.go is the template.

Cover at least:

  • a non-member is refused read and write on every route, with 404s byte-identical to the app's own for a path that does not exist;
  • a Member is refused every management action; an Admin cannot change, demote or deactivate an Admin or the Owner;
  • a posted role=owner never lands, on any path, for any actor;
  • an invited address cannot be claimed without the token;
  • a removed member still signs in and still gets 404 on /;
  • an invited signup that fails validation re-renders a form still carrying the token, and the second attempt succeeds;
  • a cross-origin POST to an idear route is refused 403;
  • the app's own Schema and Models agree (migrate.Generate returning zero changes is rastrillo migration check in test form) — which is also what catches an idear model added to Models.

Checklist before you call a mount done

  1. idear.Schema is in BootSchema; no idear model is in Models.
  2. POST /signup is wrapped in rs.CarryToken (password path).
  3. Config.NotFound and chi's NotFound are the same function value.
  4. Require is mounted inside the session guard, and / is behind it.
  5. RequireRole appears only inside Require.
  6. Site is set; ClientKey is set if there is a proxy.
  7. The role selector is built from Grantable.
  8. One identity plugin, not two.
  9. RenderSignup seeds its hidden invite field from idear.TokenFrom(r), and a test posts a failing signup to prove it — this is the piece a rewritten signup page loses silently.
  10. csrf.Protect(origin) is mounted app-wide, above every group, so it covers idear's routes as well as yours.
  11. EmailForSubject is set on the password path, or you have decided, knowingly, that a signed-in orphan may spend any token they hold.