Add API token domain model and policy evaluation
This commit is contained in:
@@ -0,0 +1,298 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
EntryTypeAPIToken = "api-token"
|
||||||
|
|
||||||
|
FieldType = "KeePassGO-Type"
|
||||||
|
FieldTokenID = "KeePassGO-API-Token-ID"
|
||||||
|
FieldClientName = "KeePassGO-API-Client-Name"
|
||||||
|
FieldCreatedAt = "KeePassGO-API-Created-At"
|
||||||
|
FieldExpiresAt = "KeePassGO-API-Expires-At"
|
||||||
|
FieldDisabled = "KeePassGO-API-Disabled"
|
||||||
|
FieldRevokedAt = "KeePassGO-API-Revoked-At"
|
||||||
|
FieldSecretHash = "KeePassGO-API-Secret-Hash"
|
||||||
|
FieldPolicies = "KeePassGO-API-Policies"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotAToken = errors.New("entry is not an api token")
|
||||||
|
ErrInvalidToken = errors.New("invalid api token")
|
||||||
|
ErrExpiredToken = errors.New("expired api token")
|
||||||
|
ErrDisabledToken = errors.New("disabled api token")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Effect string
|
||||||
|
type Operation string
|
||||||
|
type ResourceKind string
|
||||||
|
type Decision string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EffectAllow Effect = "allow"
|
||||||
|
EffectDeny Effect = "deny"
|
||||||
|
|
||||||
|
ResourceGroup ResourceKind = "group"
|
||||||
|
ResourceEntry ResourceKind = "entry"
|
||||||
|
|
||||||
|
DecisionAllow Decision = "allow"
|
||||||
|
DecisionDeny Decision = "deny"
|
||||||
|
DecisionPrompt Decision = "prompt"
|
||||||
|
|
||||||
|
OperationListEntries Operation = "list_entries"
|
||||||
|
OperationListGroups Operation = "list_groups"
|
||||||
|
OperationReadEntry Operation = "read_entry"
|
||||||
|
OperationCopyPassword Operation = "copy_password"
|
||||||
|
OperationCopyUsername Operation = "copy_username"
|
||||||
|
OperationCopyURL Operation = "copy_url"
|
||||||
|
OperationMutateEntry Operation = "mutate_entry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Resource struct {
|
||||||
|
Kind ResourceKind `json:"kind"`
|
||||||
|
Path []string `json:"path,omitempty"`
|
||||||
|
EntryID string `json:"entry_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PolicyRule struct {
|
||||||
|
Effect Effect `json:"effect"`
|
||||||
|
Operation Operation `json:"operation"`
|
||||||
|
Resource Resource `json:"resource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
ClientName string
|
||||||
|
SecretHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
Disabled bool
|
||||||
|
Policies []PolicyRule
|
||||||
|
}
|
||||||
|
|
||||||
|
func Issue(name, clientName string, expiresAt *time.Time, now time.Time) (Token, string, error) {
|
||||||
|
clear, hashed, err := newSecret()
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, "", err
|
||||||
|
}
|
||||||
|
id, _, err := newSecret()
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, "", err
|
||||||
|
}
|
||||||
|
return Token{
|
||||||
|
ID: id,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
ClientName: strings.TrimSpace(clientName),
|
||||||
|
SecretHash: hashed,
|
||||||
|
CreatedAt: now.UTC(),
|
||||||
|
ExpiresAt: cloneTime(expiresAt),
|
||||||
|
}, clear, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Rotate(token Token, now time.Time) (Token, string, error) {
|
||||||
|
clear, hashed, err := newSecret()
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, "", err
|
||||||
|
}
|
||||||
|
token.SecretHash = hashed
|
||||||
|
token.Disabled = false
|
||||||
|
token.RevokedAt = nil
|
||||||
|
if token.CreatedAt.IsZero() {
|
||||||
|
token.CreatedAt = now.UTC()
|
||||||
|
}
|
||||||
|
return token, clear, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Disable(token Token) Token {
|
||||||
|
token.Disabled = true
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func Revoke(token Token, when time.Time) Token {
|
||||||
|
token.Disabled = true
|
||||||
|
t := when.UTC()
|
||||||
|
token.RevokedAt = &t
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func Authenticate(tokens []Token, presentedSecret string, now time.Time) (Token, error) {
|
||||||
|
hashed := hashSecret(presentedSecret)
|
||||||
|
for _, token := range tokens {
|
||||||
|
if token.SecretHash != hashed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if token.Disabled || token.RevokedAt != nil {
|
||||||
|
return Token{}, ErrDisabledToken
|
||||||
|
}
|
||||||
|
if token.ExpiresAt != nil && !token.ExpiresAt.After(now.UTC()) {
|
||||||
|
return Token{}, ErrExpiredToken
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
return Token{}, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func Evaluate(token Token, operation Operation, resource Resource) Decision {
|
||||||
|
decision := DecisionPrompt
|
||||||
|
for _, rule := range token.Policies {
|
||||||
|
if rule.Operation != operation {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matches(rule.Resource, resource) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.Effect == EffectDeny {
|
||||||
|
return DecisionDeny
|
||||||
|
}
|
||||||
|
if rule.Effect == EffectAllow {
|
||||||
|
decision = DecisionAllow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func Entries(model vault.Model) ([]Token, error) {
|
||||||
|
var out []Token
|
||||||
|
for _, entry := range model.Entries {
|
||||||
|
token, ok, err := TokenFromEntry(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
out = append(out, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b Token) int {
|
||||||
|
switch {
|
||||||
|
case a.Name < b.Name:
|
||||||
|
return -1
|
||||||
|
case a.Name > b.Name:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return strings.Compare(a.ID, b.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
|
||||||
|
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
||||||
|
return Token{}, false, nil
|
||||||
|
}
|
||||||
|
createdAt, err := time.Parse(time.RFC3339, entry.Fields[FieldCreatedAt])
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, true, fmt.Errorf("parse created at: %w", err)
|
||||||
|
}
|
||||||
|
var expiresAt *time.Time
|
||||||
|
if raw := strings.TrimSpace(entry.Fields[FieldExpiresAt]); raw != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, true, fmt.Errorf("parse expires at: %w", err)
|
||||||
|
}
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
var revokedAt *time.Time
|
||||||
|
if raw := strings.TrimSpace(entry.Fields[FieldRevokedAt]); raw != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, true, fmt.Errorf("parse revoked at: %w", err)
|
||||||
|
}
|
||||||
|
revokedAt = &t
|
||||||
|
}
|
||||||
|
policies := []PolicyRule{}
|
||||||
|
if raw := strings.TrimSpace(entry.Fields[FieldPolicies]); raw != "" {
|
||||||
|
if err := json.Unmarshal([]byte(raw), &policies); err != nil {
|
||||||
|
return Token{}, true, fmt.Errorf("parse policies: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Token{
|
||||||
|
ID: entry.Fields[FieldTokenID],
|
||||||
|
Name: entry.Title,
|
||||||
|
ClientName: entry.Fields[FieldClientName],
|
||||||
|
SecretHash: entry.Fields[FieldSecretHash],
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
RevokedAt: revokedAt,
|
||||||
|
Disabled: strings.EqualFold(entry.Fields[FieldDisabled], "true"),
|
||||||
|
Policies: policies,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Token) Entry(path []string) vault.Entry {
|
||||||
|
fields := map[string]string{
|
||||||
|
FieldType: EntryTypeAPIToken,
|
||||||
|
FieldTokenID: t.ID,
|
||||||
|
FieldClientName: t.ClientName,
|
||||||
|
FieldCreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
FieldDisabled: fmt.Sprintf("%t", t.Disabled),
|
||||||
|
FieldSecretHash: t.SecretHash,
|
||||||
|
}
|
||||||
|
if t.ExpiresAt != nil {
|
||||||
|
fields[FieldExpiresAt] = t.ExpiresAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if t.RevokedAt != nil {
|
||||||
|
fields[FieldRevokedAt] = t.RevokedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if len(t.Policies) > 0 {
|
||||||
|
data, _ := json.Marshal(t.Policies)
|
||||||
|
fields[FieldPolicies] = string(data)
|
||||||
|
}
|
||||||
|
return vault.Entry{
|
||||||
|
ID: t.ID,
|
||||||
|
Title: t.Name,
|
||||||
|
Username: t.ClientName,
|
||||||
|
Path: slices.Clone(path),
|
||||||
|
Fields: fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecret(secret string) string {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSecret() (string, string, error) {
|
||||||
|
buf := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", "", fmt.Errorf("generate secret: %w", err)
|
||||||
|
}
|
||||||
|
clear := base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
return clear, hashSecret(clear), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTime(in *time.Time) *time.Time {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := in.UTC()
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(rule, resource Resource) bool {
|
||||||
|
switch rule.Kind {
|
||||||
|
case ResourceEntry:
|
||||||
|
return rule.EntryID != "" && rule.EntryID == resource.EntryID
|
||||||
|
case ResourceGroup:
|
||||||
|
if len(rule.Path) > len(resource.Path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.Equal(rule.Path, resource.Path[:len(rule.Path)])
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package apitokens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Name: "Browser Connector",
|
||||||
|
ClientName: "browser-extension",
|
||||||
|
SecretHash: "deadbeef",
|
||||||
|
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
Disabled: true,
|
||||||
|
Policies: []PolicyRule{
|
||||||
|
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := token.Entry([]string{"Root", "API Tokens"})
|
||||||
|
if entry.Fields[FieldType] != EntryTypeAPIToken {
|
||||||
|
t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok, err := TokenFromEntry(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TokenFromEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("TokenFromEntry() ok = false, want true")
|
||||||
|
}
|
||||||
|
if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled {
|
||||||
|
t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token)
|
||||||
|
}
|
||||||
|
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) {
|
||||||
|
t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt)
|
||||||
|
}
|
||||||
|
if len(got.Policies) != 2 {
|
||||||
|
t.Fatalf("len(Policies) = %d, want 2", len(got.Policies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntriesFiltersOnlyAPITokens(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Ordinary Entry"},
|
||||||
|
Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
|
||||||
|
Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Entries(model)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Entries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(Entries()) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Name != "Browser" || got[1].Name != "CLI" {
|
||||||
|
t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueRotateDisableAndRevokeToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
token, secret, err := Issue("Browser Connector", "browser-extension", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue() error = %v", err)
|
||||||
|
}
|
||||||
|
if token.ID == "" || secret == "" {
|
||||||
|
t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret)
|
||||||
|
}
|
||||||
|
if token.SecretHash == secret {
|
||||||
|
t.Fatal("SecretHash should not equal cleartext secret")
|
||||||
|
}
|
||||||
|
if token.Disabled {
|
||||||
|
t.Fatal("Disabled = true, want false after Issue")
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated, newSecret, err := Rotate(token, now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rotate() error = %v", err)
|
||||||
|
}
|
||||||
|
if rotated.ID != token.ID {
|
||||||
|
t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID)
|
||||||
|
}
|
||||||
|
if newSecret == secret {
|
||||||
|
t.Fatal("Rotate() returned the same cleartext secret, want a new one")
|
||||||
|
}
|
||||||
|
if rotated.SecretHash == token.SecretHash {
|
||||||
|
t.Fatal("Rotate() left SecretHash unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled := Disable(rotated)
|
||||||
|
if !disabled.Disabled {
|
||||||
|
t.Fatal("Disable() did not set Disabled")
|
||||||
|
}
|
||||||
|
revoked := Revoke(disabled, now.Add(2*time.Hour))
|
||||||
|
if !revoked.Disabled {
|
||||||
|
t.Fatal("Revoke() should leave token disabled")
|
||||||
|
}
|
||||||
|
if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) {
|
||||||
|
t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
valid, secret, err := Issue("CLI", "cli-tool", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue() error = %v", err)
|
||||||
|
}
|
||||||
|
expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue(expired) error = %v", err)
|
||||||
|
}
|
||||||
|
expiredAt := now.Add(-time.Minute)
|
||||||
|
expired.ExpiresAt = &expiredAt
|
||||||
|
disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue(disabled) error = %v", err)
|
||||||
|
}
|
||||||
|
disabled = Disable(disabled)
|
||||||
|
|
||||||
|
tokens := []Token{expired, disabled, valid}
|
||||||
|
if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken {
|
||||||
|
t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken)
|
||||||
|
}
|
||||||
|
if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken {
|
||||||
|
t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken)
|
||||||
|
}
|
||||||
|
if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken {
|
||||||
|
t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken)
|
||||||
|
}
|
||||||
|
got, err := Authenticate(tokens, secret, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Authenticate(valid) error = %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != valid.ID {
|
||||||
|
t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Policies: []PolicyRule{
|
||||||
|
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}})
|
||||||
|
if decision != DecisionAllow {
|
||||||
|
t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}})
|
||||||
|
if decision != DecisionDeny {
|
||||||
|
t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}})
|
||||||
|
if decision != DecisionPrompt {
|
||||||
|
t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Policies: []PolicyRule{
|
||||||
|
{Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}})
|
||||||
|
denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}})
|
||||||
|
if allowed != DecisionAllow || denied != DecisionDeny {
|
||||||
|
t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
token := Token{
|
||||||
|
ID: "token-1",
|
||||||
|
Name: "CLI",
|
||||||
|
ClientName: "cli",
|
||||||
|
SecretHash: "hash",
|
||||||
|
CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
|
||||||
|
Policies: []PolicyRule{
|
||||||
|
{Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}},
|
||||||
|
{Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"}))
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok)
|
||||||
|
}
|
||||||
|
if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool {
|
||||||
|
return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path)
|
||||||
|
}) {
|
||||||
|
t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user