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>
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 −0Makefile+18 −0member.go+22 −0policy.go+49 −0policy_test.go+91 −0role.go+81 −0role_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) |
| + } |
| + } |
| +} |