add token type with permission checks and composable matchers
This commit is contained in:
parent
5288df0b9e
commit
8b5fcc87c3
4 changed files with 499 additions and 0 deletions
92
matcher.go
Normal file
92
matcher.go
Normal 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
91
matcher_test.go
Normal 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
114
token.go
Normal 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
202
token_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue