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