rastrillo / idear Public

idear: Role, MayActOn, and the CI plumbing (Task 1)

Pure policy layer with no database and no HTTP, so the four MayActOn
rules and the AtLeast ordering can be exhausted in a table test before
anything about storage exists. The policy table states every actor/
target cell by hand rather than deriving the expectation from rank() —
round 1 of the idear bake-off found a test that whitelisted the very
payload it was supposed to be checking, and a want column computed from
the implementation's own comparison would repeat that mistake.

Member here is intentionally minimal (ID, Role, DeactivatedAt, Active())
— just enough for MayActOn to compile and be tested. Task 2 grows it
into the full GORM model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d152346b700583f54c9bc6a178f24e28fefd9bf2 parent fc16de1
11 files changed, +422 −0
  • .amadan/ci +6 −0
  • .amadan/ci.d/10-vet +2 −0
  • .amadan/ci.d/20-fmt +2 −0
  • .amadan/ci.d/30-test +2 −0
  • .gitignore +2 −0
  • Makefile +18 −0
  • member.go +22 −0
  • policy.go +49 −0
  • policy_test.go +91 −0
  • role.go +81 −0
  • role_test.go +147 −0
diff --git a/.amadan/ci b/.amadan/ci
new file mode 100755
index 0000000..8c547ee
--- /dev/null
+++ b/.amadan/ci
@@ -0,0 +1,6 @@
+#!/bin/sh
+# amadan CI entry (single-script fallback for runners without step
+# support). The steps in ci.d/ are the same targets, reported one by
+# one. Must stay executable: a non-executable script resolves "skipped".
+set -e
+exec make ci
diff --git a/.amadan/ci.d/10-vet b/.amadan/ci.d/10-vet
new file mode 100755
index 0000000..139a2a2
--- /dev/null
+++ b/.amadan/ci.d/10-vet
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make vet
diff --git a/.amadan/ci.d/20-fmt b/.amadan/ci.d/20-fmt
new file mode 100755
index 0000000..5884a7b
--- /dev/null
+++ b/.amadan/ci.d/20-fmt
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make fmt-check
diff --git a/.amadan/ci.d/30-test b/.amadan/ci.d/30-test
new file mode 100755
index 0000000..758ffe2
--- /dev/null
+++ b/.amadan/ci.d/30-test
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ca6ec13
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+# Build output only — nothing in here is source.
+/releases/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..88a7009
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,18 @@
+.PHONY: vet fmt-check test ci
+
+# ci is the one gate: what a runner executes and what you run before
+# pushing are the same definition (amadan's own rule — CI steps delegate
+# to make targets, never keep their own copies of the commands). Task 1
+# is pure Go with no database and no HTTP, so vet + fmt-check + test is
+# the whole gate; later tasks add migration-check the way the scaffold's
+# own Makefile does, once there is a schema to check.
+ci: vet fmt-check test
+
+vet:
+ go vet ./...
+
+fmt-check:
+ @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
+
+test:
+ go test ./...
diff --git a/member.go b/member.go
new file mode 100644
index 0000000..cd496b1
--- /dev/null
+++ b/member.go
@@ -0,0 +1,22 @@
+package idear
+
+import "time"
+
+// Member is a row in the roster: one person, at one role, in one
+// instance. This is the minimal shape policy.go needs to compile and
+// be tested without a database — ID, Role, and DeactivatedAt are the
+// only fields MayActOn reads. Task 2 grows this into the full GORM
+// model (Subject, Email, Name, CreatedAt, UpdatedAt) once storage
+// exists to back them; nothing here should be read as the final shape.
+type Member struct {
+ ID int64
+ Role Role
+ DeactivatedAt *time.Time
+}
+
+// Active reports whether m exists and has not been deactivated. A nil
+// Member is never active, so callers can pass a lookup's zero value
+// straight in without a separate nil check.
+func (m *Member) Active() bool {
+ return m != nil && m.DeactivatedAt == nil
+}
diff --git a/policy.go b/policy.go
new file mode 100644
index 0000000..d77ad60
--- /dev/null
+++ b/policy.go
@@ -0,0 +1,49 @@
+package idear
+
+import (
+ "errors"
+ "fmt"
+)
+
+// ErrForbidden is the sentinel every MayActOn refusal wraps. Callers
+// that only need the yes/no answer use errors.Is; the wrapped text
+// carries the reason for logs and error pages.
+var ErrForbidden = errors.New("idear: forbidden")
+
+// MayActOn reports whether actor may manage target — change target's
+// role, deactivate, or reactivate them. It is a pure function so the
+// full role matrix can be exhausted in a table test with no database
+// and no HTTP server.
+//
+// Four rules, in order, each closing a specific hole:
+//
+// 1. actor must be non-nil and active — a deactivated admin keeps
+// their row (removal is never a delete) but loses every privilege
+// the row once carried.
+// 2. actor must be at least Admin — a Member can see the roster but
+// never act on it.
+// 3. actor and target must be different people, compared by ID — an
+// Owner deactivating or demoting themselves is exactly how an
+// instance ends up with no one able to administer it.
+// 4. target's rank must be strictly below actor's — Admin manages
+// Member only, Owner manages Admin and Member, and nobody at any
+// rank manages an Owner. Equal rank is refused, not just higher
+// rank: two Owners or two Admins may never act on one another.
+func MayActOn(actor, target *Member) error {
+ if actor == nil || !actor.Active() {
+ return fmt.Errorf("%w: actor is not an active member", ErrForbidden)
+ }
+ if !actor.Role.AtLeast(RoleAdmin) {
+ return fmt.Errorf("%w: actor must be at least admin", ErrForbidden)
+ }
+ if target == nil {
+ return fmt.Errorf("%w: no target", ErrForbidden)
+ }
+ if actor.ID == target.ID {
+ return fmt.Errorf("%w: cannot act on self", ErrForbidden)
+ }
+ if rank(target.Role) >= rank(actor.Role) {
+ return fmt.Errorf("%w: target's rank is not below actor's", ErrForbidden)
+ }
+ return nil
+}
diff --git a/policy_test.go b/policy_test.go
new file mode 100644
index 0000000..ae64c84
--- /dev/null
+++ b/policy_test.go
@@ -0,0 +1,91 @@
+package idear
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+// member is a small constructor for test fixtures: an active member
+// with the given id and role, or a deactivated one when deactivated is
+// true. Kept separate from the struct literals below so each case in
+// the table reads as data, not setup logic.
+func member(id int64, role Role, deactivated bool) *Member {
+ m := &Member{ID: id, Role: role}
+ if deactivated {
+ t := time.Unix(0, 0)
+ m.DeactivatedAt = &t
+ }
+ return m
+}
+
+// TestMayActOn_Matrix states, cell by cell, whether actor may act on
+// target. Every want is written down by hand rather than recomputed
+// from rank() or AtLeast() — a test that re-derives the implementation's
+// own comparison proves only that the comparison equals itself, which
+// is exactly the bug round 1's auditor found in the allow-list tests.
+func TestMayActOn_Matrix(t *testing.T) {
+ // Distinct ids on both sides so "different person" cases are never
+ // accidentally also self-action cases.
+ activeOwner := member(1, RoleOwner, false)
+ activeAdmin := member(2, RoleAdmin, false)
+ activeMember := member(3, RoleMember, false)
+
+ otherOwner := member(10, RoleOwner, false)
+ otherAdmin := member(20, RoleAdmin, false)
+ otherMember := member(30, RoleMember, false)
+
+ deactivatedOwner := member(1, RoleOwner, true)
+ deactivatedAdmin := member(2, RoleAdmin, true)
+
+ cases := []struct {
+ name string
+ actor *Member
+ target *Member
+ wantErr bool
+ }{
+ // The full 3x3 actor/target matrix, actor and target always
+ // distinct people.
+ {"owner acts on other owner", activeOwner, otherOwner, true},
+ {"owner acts on admin", activeOwner, otherAdmin, false},
+ {"owner acts on member", activeOwner, otherMember, false},
+ {"admin acts on owner", activeAdmin, otherOwner, true},
+ {"admin acts on other admin", activeAdmin, otherAdmin, true},
+ {"admin acts on member", activeAdmin, otherMember, false},
+ {"member acts on owner", activeMember, otherOwner, true},
+ {"member acts on admin", activeMember, otherAdmin, true},
+ {"member acts on other member", activeMember, otherMember, true},
+
+ // Self-action: refused for every rank, including the Owner.
+ {"owner acts on self", activeOwner, member(1, RoleOwner, false), true},
+ {"admin acts on self", activeAdmin, member(2, RoleAdmin, false), true},
+ {"member acts on self", activeMember, member(3, RoleMember, false), true},
+
+ // Inactive actor: refused regardless of rank, before rank is
+ // even considered.
+ {"deactivated owner acts on member", deactivatedOwner, otherMember, true},
+ {"deactivated admin acts on member", deactivatedAdmin, otherMember, true},
+
+ // Defensive: a nil actor or nil target can never be permitted.
+ {"nil actor", nil, otherMember, true},
+ {"nil target", activeOwner, nil, true},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ err := MayActOn(c.actor, c.target)
+ if c.wantErr {
+ if err == nil {
+ t.Fatalf("MayActOn(%v, %v) = nil, want ErrForbidden", c.actor, c.target)
+ }
+ if !errors.Is(err, ErrForbidden) {
+ t.Fatalf("MayActOn(%v, %v) = %v, want wrapped ErrForbidden", c.actor, c.target, err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("MayActOn(%v, %v) = %v, want nil", c.actor, c.target, err)
+ }
+ })
+ }
+}
diff --git a/role.go b/role.go
new file mode 100644
index 0000000..6cfb85f
--- /dev/null
+++ b/role.go
@@ -0,0 +1,81 @@
+// Package idear is the roster for a Rastrillo instance: who is in it,
+// at what role, and who may change that. It never mints a session,
+// hashes a password, or renders a sign-in form — it sits on top of
+// sessions and whichever identity plugin the app already chose.
+package idear
+
+// Role is a member's rank within one instance. It is a string, not an
+// int, because it round-trips through form posts and database columns
+// without a mapping table to keep in sync — but that means any string
+// decodes without error, so every caller that receives one from outside
+// the package (a form, a query) must run it through ParseRole rather
+// than trust the cast.
+type Role string
+
+const (
+ RoleOwner Role = "owner"
+ RoleAdmin Role = "admin"
+ RoleMember Role = "member"
+)
+
+// rank orders the three roles for comparison; 0 means "not a role; see
+// Valid" rather than "below Member" so it never wins a comparison it
+// has no business winning.
+func rank(r Role) int {
+ switch r {
+ case RoleOwner:
+ return 3
+ case RoleAdmin:
+ return 2
+ case RoleMember:
+ return 1
+ default:
+ return 0
+ }
+}
+
+// Valid reports whether r is one of the three known roles.
+func (r Role) Valid() bool {
+ return rank(r) > 0
+}
+
+// AtLeast reports whether r's rank is at or above min's. Both sides
+// must be valid roles — an invalid r is never at least anything, not
+// even an equally invalid min, and an invalid min is never a threshold
+// anything can clear. Without that second half, two unknown strings
+// would compare equal by falling through to the same rank(0), and
+// AtLeast would call garbage "at least" garbage.
+func (r Role) AtLeast(min Role) bool {
+ if !r.Valid() || !min.Valid() {
+ return false
+ }
+ return rank(r) >= rank(min)
+}
+
+// Title is the display form of r — "Owner", "Admin", "Member" — and
+// the empty string for anything that isn't a role, so a template that
+// prints it renders nothing rather than a raw lowercase form value.
+func (r Role) Title() string {
+ switch r {
+ case RoleOwner:
+ return "Owner"
+ case RoleAdmin:
+ return "Admin"
+ case RoleMember:
+ return "Member"
+ default:
+ return ""
+ }
+}
+
+// ParseRole accepts only the three known lowercase spellings. It is the
+// one place a string from outside the package (a posted form field, a
+// query parameter) becomes a Role; everywhere else in idear a Role is
+// assumed already valid.
+func ParseRole(s string) (Role, bool) {
+ r := Role(s)
+ if !r.Valid() {
+ return "", false
+ }
+ return r, true
+}
diff --git a/role_test.go b/role_test.go
new file mode 100644
index 0000000..3261a75
--- /dev/null
+++ b/role_test.go
@@ -0,0 +1,147 @@
+package idear
+
+import "testing"
+
+// The six spellings every AtLeast case below is drawn from: the three
+// valid roles, plus three shapes an app might plausibly hand in — empty,
+// upper-cased, and a made-up name — none of which is a role.
+const (
+ rOwner = RoleOwner
+ rAdmin = RoleAdmin
+ rMember = RoleMember
+ rEmpty = Role("")
+ rUpper = Role("OWNER")
+ rBogus = Role("root")
+)
+
+// TestRole_AtLeast enumerates every ordered pair of the six spellings
+// above by hand. Each want is written down, not computed from rank() —
+// a table that recomputes the implementation's own formula would only
+// prove the formula equals itself.
+func TestRole_AtLeast(t *testing.T) {
+ cases := []struct {
+ r, min Role
+ want bool
+ }{
+ // r = owner
+ {rOwner, rOwner, true},
+ {rOwner, rAdmin, true},
+ {rOwner, rMember, true},
+ {rOwner, rEmpty, false},
+ {rOwner, rUpper, false},
+ {rOwner, rBogus, false},
+
+ // r = admin
+ {rAdmin, rOwner, false},
+ {rAdmin, rAdmin, true},
+ {rAdmin, rMember, true},
+ {rAdmin, rEmpty, false},
+ {rAdmin, rUpper, false},
+ {rAdmin, rBogus, false},
+
+ // r = member
+ {rMember, rOwner, false},
+ {rMember, rAdmin, false},
+ {rMember, rMember, true},
+ {rMember, rEmpty, false},
+ {rMember, rUpper, false},
+ {rMember, rBogus, false},
+
+ // r = "" (invalid) — false against every minimum, valid or not
+ {rEmpty, rOwner, false},
+ {rEmpty, rAdmin, false},
+ {rEmpty, rMember, false},
+ {rEmpty, rEmpty, false},
+ {rEmpty, rUpper, false},
+ {rEmpty, rBogus, false},
+
+ // r = "OWNER" (invalid) — false against every minimum, valid or not
+ {rUpper, rOwner, false},
+ {rUpper, rAdmin, false},
+ {rUpper, rMember, false},
+ {rUpper, rEmpty, false},
+ {rUpper, rUpper, false},
+ {rUpper, rBogus, false},
+
+ // r = "root" (invalid) — false against every minimum, valid or not
+ {rBogus, rOwner, false},
+ {rBogus, rAdmin, false},
+ {rBogus, rMember, false},
+ {rBogus, rEmpty, false},
+ {rBogus, rUpper, false},
+ {rBogus, rBogus, false},
+ }
+
+ if len(cases) != 36 {
+ t.Fatalf("expected all 36 ordered pairs of 6 spellings, got %d", len(cases))
+ }
+
+ for _, c := range cases {
+ got := c.r.AtLeast(c.min)
+ if got != c.want {
+ t.Errorf("Role(%q).AtLeast(%q) = %v, want %v", c.r, c.min, got, c.want)
+ }
+ }
+}
+
+func TestRole_Valid(t *testing.T) {
+ cases := []struct {
+ r Role
+ want bool
+ }{
+ {rOwner, true},
+ {rAdmin, true},
+ {rMember, true},
+ {rEmpty, false},
+ {rUpper, false},
+ {rBogus, false},
+ }
+ for _, c := range cases {
+ if got := c.r.Valid(); got != c.want {
+ t.Errorf("Role(%q).Valid() = %v, want %v", c.r, got, c.want)
+ }
+ }
+}
+
+func TestRole_Title(t *testing.T) {
+ cases := []struct {
+ r Role
+ want string
+ }{
+ {rOwner, "Owner"},
+ {rAdmin, "Admin"},
+ {rMember, "Member"},
+ {rEmpty, ""},
+ {rUpper, ""},
+ {rBogus, ""},
+ }
+ for _, c := range cases {
+ if got := c.r.Title(); got != c.want {
+ t.Errorf("Role(%q).Title() = %q, want %q", c.r, got, c.want)
+ }
+ }
+}
+
+func TestParseRole(t *testing.T) {
+ accept := []struct {
+ s string
+ want Role
+ }{
+ {"owner", RoleOwner},
+ {"admin", RoleAdmin},
+ {"member", RoleMember},
+ }
+ for _, c := range accept {
+ got, ok := ParseRole(c.s)
+ if !ok || got != c.want {
+ t.Errorf("ParseRole(%q) = (%q, %v), want (%q, true)", c.s, got, ok, c.want)
+ }
+ }
+
+ reject := []string{"", "OWNER", "root", "Owner"}
+ for _, s := range reject {
+ if got, ok := ParseRole(s); ok {
+ t.Errorf("ParseRole(%q) = (%q, true), want ok=false", s, got)
+ }
+ }
+}