add token type with permission checks and composable matchers

This commit is contained in:
juancwu 2026-04-29 01:58:46 +00:00
commit 8b5fcc87c3
4 changed files with 499 additions and 0 deletions

92
matcher.go Normal file
View file

@ -0,0 +1,92 @@
package ficha
// Matcher is a composable predicate evaluated against a Token.
// Use the package-level constructors All, Any, Not, Perm, and Check
// (on Token) to combine them.
type Matcher interface {
matches(t *Token) bool
}
// Perm returns a Matcher that checks for a single permission.
func Perm(p string) Matcher { return permMatcher(p) }
// All returns a Matcher that holds when every child matcher holds.
// Permission strings are accepted directly and treated as Perm(s).
// All() with no arguments holds (vacuous truth).
func All(matchers ...any) Matcher { return andMatcher(toMatchers(matchers)) }
// Any returns a Matcher that holds when at least one child matcher holds.
// Any() with no arguments does not hold.
func Any(matchers ...any) Matcher { return orMatcher(toMatchers(matchers)) }
// Not returns a Matcher that holds when the inner matcher does not.
// Strings are accepted and treated as Perm(s).
func Not(m any) Matcher {
return notMatcher{inner: toMatcher(m)}
}
// Check evaluates a Matcher against the token.
func (t *Token) Check(m Matcher) bool {
if m == nil {
return false
}
return m.matches(t)
}
// --- internal matcher implementations ---
type permMatcher string
func (p permMatcher) matches(t *Token) bool { return t.Has(string(p)) }
type andMatcher []Matcher
func (a andMatcher) matches(t *Token) bool {
for _, m := range a {
if !m.matches(t) {
return false
}
}
return true
}
type orMatcher []Matcher
func (o orMatcher) matches(t *Token) bool {
for _, m := range o {
if m.matches(t) {
return true
}
}
return false
}
type notMatcher struct{ inner Matcher }
func (n notMatcher) matches(t *Token) bool { return !n.inner.matches(t) }
// toMatcher accepts either a string (treated as Perm) or a Matcher.
// Anything else produces a matcher that never holds — defensive but
// not panicking, since Matcher trees are often built dynamically.
func toMatcher(x any) Matcher {
switch v := x.(type) {
case string:
return permMatcher(v)
case Matcher:
return v
default:
return alwaysFalse{}
}
}
func toMatchers(xs []any) []Matcher {
out := make([]Matcher, len(xs))
for i, x := range xs {
out[i] = toMatcher(x)
}
return out
}
type alwaysFalse struct{}
func (alwaysFalse) matches(*Token) bool { return false }

91
matcher_test.go Normal file
View file

@ -0,0 +1,91 @@
package ficha
import "testing"
func TestPermMatcher(t *testing.T) {
tok := makeToken("read", "write")
if !tok.Check(Perm("read")) {
t.Error("Perm(read) should match")
}
if tok.Check(Perm("delete")) {
t.Error("Perm(delete) should not match")
}
}
func TestAllMatcher(t *testing.T) {
tok := makeToken("a", "b", "c")
if !tok.Check(All("a", "b")) {
t.Error("All(a,b) should match")
}
if tok.Check(All("a", "z")) {
t.Error("All(a,z) should not match")
}
if !tok.Check(All()) {
t.Error("All() empty should match (vacuous truth)")
}
}
func TestAnyMatcher(t *testing.T) {
tok := makeToken("a")
if !tok.Check(Any("a", "z")) {
t.Error("Any(a,z) should match")
}
if tok.Check(Any("x", "y")) {
t.Error("Any(x,y) should not match")
}
if tok.Check(Any()) {
t.Error("Any() empty should not match")
}
}
func TestNotMatcher(t *testing.T) {
tok := makeToken("a")
if !tok.Check(Not("z")) {
t.Error("Not(z) should match")
}
if tok.Check(Not("a")) {
t.Error("Not(a) should not match")
}
}
func TestComposedMatchers(t *testing.T) {
tok := makeToken("orders:read", "billing-manager")
// (orders:read) AND (admin OR billing-manager) AND NOT readonly
m := All(
"orders:read",
Any("admin", "billing-manager"),
Not("readonly"),
)
if !tok.Check(m) {
t.Error("composed matcher should hold")
}
// Same with readonly added — Not should fail.
tokRO := makeToken("orders:read", "billing-manager", "readonly")
if tokRO.Check(m) {
t.Error("composed matcher should fail when readonly is present")
}
}
func TestMatcherWithUnknownType(t *testing.T) {
tok := makeToken("a")
// Passing something that isn't a string or Matcher should
// produce a matcher that never holds — fail closed.
m := All(123, "a") // 123 is the bad input
if tok.Check(m) {
t.Error("matcher with unknown type should fail closed")
}
}
func TestCheckNilMatcher(t *testing.T) {
tok := makeToken("a")
if tok.Check(nil) {
t.Error("Check(nil) should return false")
}
}

114
token.go Normal file
View file

@ -0,0 +1,114 @@
package ficha
import (
"encoding/json"
"time"
)
// Token is a validated, decrypted token. It exposes the consumer-defined
// permissions and data, and provides methods for permission checks.
//
// Tokens are returned by Issuer.Validate. Consumers do not construct them
// directly. A Token is read-only — its methods do not mutate state.
type Token struct {
id string
issuedAt time.Time
expiresAt time.Time
permissions []string
data json.RawMessage
}
// newToken builds a Token from a decoded payload. Package-private —
// only the Issuer constructs Tokens, after successful validation.
func newToken(p payload) *Token {
return &Token{
id: p.ID,
issuedAt: time.Unix(p.Iat, 0),
expiresAt: time.Unix(p.Exp, 0),
permissions: p.Permissions,
data: p.Data,
}
}
// ID returns the unique token identifier (used by the revocation store).
func (t *Token) ID() string { return t.id }
// IssuedAt returns when the token was issued.
func (t *Token) IssuedAt() time.Time { return t.issuedAt }
// ExpiresAt returns when the token expires.
func (t *Token) ExpiresAt() time.Time { return t.expiresAt }
// Permissions returns a copy of the token's permission strings.
// The returned slice is safe to retain and modify.
func (t *Token) Permissions() []string {
out := make([]string, len(t.permissions))
copy(out, t.permissions)
return out
}
// UnmarshalData decodes the consumer-defined data blob into v.
// Returns nil with v unchanged if the token has no data.
func (t *Token) UnmarshalData(v any) error {
if len(t.data) == 0 {
return nil
}
return json.Unmarshal(t.data, v)
}
// Has reports whether the token holds the given permission (exact match).
func (t *Token) Has(perm string) bool {
for _, p := range t.permissions {
if p == perm {
return true
}
}
return false
}
// HasAll reports whether the token holds every given permission.
// Returns true when called with no arguments (vacuous truth). For a
// fail-closed variant that returns false on empty input, see RequiresAll.
func (t *Token) HasAll(perms ...string) bool {
for _, p := range perms {
if !t.Has(p) {
return false
}
}
return true
}
// RequiresAll reports whether the token holds every given permission,
// and requires that at least one permission be specified. Returns false
// on empty input — use this when an empty permission list indicates a
// programmer error rather than a public/no-auth route.
//
// For the vacuous-truth variant (empty input → true), use HasAll.
func (t *Token) RequiresAll(perms ...string) bool {
if len(perms) == 0 {
return false
}
return t.HasAll(perms...)
}
// HasAny reports whether the token holds at least one of the given permissions.
// Returns false when called with no arguments.
func (t *Token) HasAny(perms ...string) bool {
for _, p := range perms {
if t.Has(p) {
return true
}
}
return false
}
// HasNone reports whether the token holds none of the given permissions.
// Returns true when called with no arguments.
func (t *Token) HasNone(perms ...string) bool {
for _, p := range perms {
if t.Has(p) {
return false
}
}
return true
}

202
token_test.go Normal file
View file

@ -0,0 +1,202 @@
package ficha
import (
"encoding/json"
"testing"
"time"
)
func makeToken(perms ...string) *Token {
return &Token{
id: "tok_test",
issuedAt: time.Unix(1_700_000_000, 0),
expiresAt: time.Unix(1_700_003_600, 0),
permissions: perms,
}
}
func TestTokenAccessors(t *testing.T) {
tok := makeToken("read", "write")
if tok.ID() != "tok_test" {
t.Errorf("ID: got %q", tok.ID())
}
if got := tok.IssuedAt().Unix(); got != 1_700_000_000 {
t.Errorf("IssuedAt: got %d", got)
}
if got := tok.ExpiresAt().Unix(); got != 1_700_003_600 {
t.Errorf("ExpiresAt: got %d", got)
}
perms := tok.Permissions()
if len(perms) != 2 || perms[0] != "read" || perms[1] != "write" {
t.Errorf("Permissions: got %v", perms)
}
}
func TestTokenPermissionsIsCopy(t *testing.T) {
tok := makeToken("read", "write")
perms := tok.Permissions()
perms[0] = "tampered"
if tok.Has("tampered") {
t.Error("mutating returned slice affected token state")
}
}
func TestTokenHas(t *testing.T) {
tok := makeToken("read", "write", "admin")
cases := map[string]bool{
"read": true,
"write": true,
"admin": true,
"delete": false,
"": false,
}
for perm, want := range cases {
if got := tok.Has(perm); got != want {
t.Errorf("Has(%q): got %v, want %v", perm, got, want)
}
}
}
func TestTokenHasAll(t *testing.T) {
tok := makeToken("a", "b", "c")
tests := []struct {
name string
perms []string
want bool
}{
{"all present", []string{"a", "b"}, true},
{"all three", []string{"a", "b", "c"}, true},
{"one missing", []string{"a", "z"}, false},
{"none present", []string{"x", "y"}, false},
{"empty input is vacuously true", []string{}, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tok.HasAll(tc.perms...); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestTokenHasAny(t *testing.T) {
tok := makeToken("a", "b")
tests := []struct {
name string
perms []string
want bool
}{
{"one matches", []string{"a", "z"}, true},
{"all match", []string{"a", "b"}, true},
{"none match", []string{"x", "y"}, false},
{"empty input is false", []string{}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tok.HasAny(tc.perms...); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestTokenHasNone(t *testing.T) {
tok := makeToken("a", "b")
tests := []struct {
name string
perms []string
want bool
}{
{"none of them present", []string{"x", "y"}, true},
{"one is present", []string{"a", "y"}, false},
{"empty input is vacuously true", []string{}, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tok.HasNone(tc.perms...); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestTokenUnmarshalData(t *testing.T) {
type meta struct {
UserID string `json:"user_id"`
Tier int `json:"tier"`
}
raw, _ := json.Marshal(meta{UserID: "u_42", Tier: 3})
tok := &Token{data: raw}
var got meta
if err := tok.UnmarshalData(&got); err != nil {
t.Fatalf("UnmarshalData: %v", err)
}
if got.UserID != "u_42" || got.Tier != 3 {
t.Errorf("got %+v", got)
}
}
func TestTokenUnmarshalDataEmpty(t *testing.T) {
tok := &Token{}
var got map[string]any
if err := tok.UnmarshalData(&got); err != nil {
t.Errorf("expected nil error on empty data, got %v", err)
}
}
func TestNewToken(t *testing.T) {
p := payload{
ID: "tok_x",
Iat: 1_700_000_000,
Exp: 1_700_003_600,
Permissions: []string{"read"},
Data: json.RawMessage(`{"x":1}`),
}
tok := newToken(p)
if tok.ID() != "tok_x" {
t.Errorf("ID: got %q", tok.ID())
}
if !tok.Has("read") {
t.Error("expected Has(read)")
}
if tok.IssuedAt().Unix() != 1_700_000_000 {
t.Errorf("IssuedAt: %v", tok.IssuedAt())
}
}
func TestTokenRequiresAll(t *testing.T) {
tok := makeToken("a", "b", "c")
tests := []struct {
name string
perms []string
want bool
}{
{"all present", []string{"a", "b"}, true},
{"all three", []string{"a", "b", "c"}, true},
{"one missing", []string{"a", "z"}, false},
{"none present", []string{"x", "y"}, false},
{"empty input fails closed", []string{}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tok.RequiresAll(tc.perms...); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}