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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user