From 8b5fcc87c3907c9e2e789c367cecd85bd0619424 Mon Sep 17 00:00:00 2001 From: juancwu Date: Wed, 29 Apr 2026 01:58:46 +0000 Subject: [PATCH] add token type with permission checks and composable matchers --- matcher.go | 92 ++++++++++++++++++++++ matcher_test.go | 91 ++++++++++++++++++++++ token.go | 114 +++++++++++++++++++++++++++ token_test.go | 202 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 499 insertions(+) create mode 100644 matcher.go create mode 100644 matcher_test.go create mode 100644 token.go create mode 100644 token_test.go diff --git a/matcher.go b/matcher.go new file mode 100644 index 0000000..c9db53a --- /dev/null +++ b/matcher.go @@ -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 } diff --git a/matcher_test.go b/matcher_test.go new file mode 100644 index 0000000..f58039b --- /dev/null +++ b/matcher_test.go @@ -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") + } +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..19bf3f3 --- /dev/null +++ b/token.go @@ -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 +} diff --git a/token_test.go b/token_test.go new file mode 100644 index 0000000..8777e20 --- /dev/null +++ b/token_test.go @@ -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) + } + }) + } +}