Add API approval broker for gRPC authorization prompts

This commit is contained in:
Joe Julian
2026-03-29 23:09:36 -07:00
parent dba0bf1f2c
commit 722d2eefa0
4 changed files with 684 additions and 82 deletions
+215
View File
@@ -0,0 +1,215 @@
package apiapproval
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"sync"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
var (
ErrRequestDenied = errors.New("authorization request denied")
ErrRequestCanceled = errors.New("authorization request canceled")
ErrRequestTimedOut = errors.New("authorization request timed out")
ErrRequestNotFound = errors.New("authorization request not found")
)
type Outcome string
const (
OutcomeAllowOnce Outcome = "allow-once"
OutcomeDenyOnce Outcome = "deny-once"
OutcomeAllowPermanent Outcome = "allow-permanent"
OutcomeDenyPermanent Outcome = "deny-permanent"
OutcomeCancel Outcome = "cancel"
)
type Request struct {
ID string
TokenID string
TokenName string
ClientName string
Operation apitokens.Operation
Resource apitokens.Resource
RequestedAt time.Time
}
type Result struct {
Outcome Outcome
Rule *apitokens.PolicyRule
}
type Broker struct {
mu sync.Mutex
pending map[string]*pendingRequest
timeout time.Duration
now func() time.Time
nextID func() string
}
type pendingRequest struct {
request Request
done chan Outcome
}
type idGenerator struct {
mu sync.Mutex
counter int
}
func NewBroker(timeout time.Duration) *Broker {
gen := &idGenerator{}
return &Broker{
pending: map[string]*pendingRequest{},
timeout: timeout,
now: func() time.Time {
return time.Now().UTC()
},
nextID: func() string {
return gen.Next()
},
}
}
func (g *idGenerator) Next() string {
g.mu.Lock()
defer g.mu.Unlock()
g.counter++
return "approval-" + strconv.Itoa(g.counter)
}
func (b *Broker) Pending() []Request {
b.mu.Lock()
defer b.mu.Unlock()
requests := make([]Request, 0, len(b.pending))
for _, pending := range b.pending {
requests = append(requests, pending.request)
}
slices.SortFunc(requests, func(a, c Request) int {
switch {
case a.RequestedAt.Before(c.RequestedAt):
return -1
case a.RequestedAt.After(c.RequestedAt):
return 1
case a.ID < c.ID:
return -1
case a.ID > c.ID:
return 1
default:
return 0
}
})
return requests
}
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
if b == nil {
return Result{}, ErrRequestTimedOut
}
pending := &pendingRequest{
request: Request{
ID: b.nextID(),
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
RequestedAt: b.now(),
},
done: make(chan Outcome, 1),
}
b.mu.Lock()
b.pending[pending.request.ID] = pending
b.mu.Unlock()
defer func() {
b.mu.Lock()
delete(b.pending, pending.request.ID)
b.mu.Unlock()
}()
timer := time.NewTimer(b.timeout)
defer timer.Stop()
select {
case outcome := <-pending.done:
result, err := resultForOutcome(pending.request, outcome)
if err != nil {
return result, err
}
return result, nil
case <-timer.C:
return Result{}, ErrRequestTimedOut
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
func (b *Broker) Resolve(id string, outcome Outcome) (Request, *apitokens.PolicyRule, error) {
b.mu.Lock()
pending, ok := b.pending[id]
b.mu.Unlock()
if !ok {
return Request{}, nil, ErrRequestNotFound
}
rule, err := RuleFromDecision(pending.request, outcome)
if err != nil {
return Request{}, nil, err
}
select {
case pending.done <- outcome:
default:
}
return pending.request, rule, nil
}
func resultForOutcome(request Request, outcome Outcome) (Result, error) {
rule, err := RuleFromDecision(request, outcome)
if err != nil {
return Result{}, err
}
result := Result{Outcome: outcome, Rule: rule}
switch outcome {
case OutcomeAllowOnce, OutcomeAllowPermanent:
return result, nil
case OutcomeDenyOnce, OutcomeDenyPermanent:
return result, ErrRequestDenied
case OutcomeCancel:
return result, ErrRequestCanceled
default:
return Result{}, fmt.Errorf("unsupported approval outcome %q", outcome)
}
}
func RuleFromDecision(request Request, outcome Outcome) (*apitokens.PolicyRule, error) {
switch outcome {
case OutcomeAllowPermanent:
rule := apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: request.Operation,
Resource: request.Resource,
}
return &rule, nil
case OutcomeDenyPermanent:
rule := apitokens.PolicyRule{
Effect: apitokens.EffectDeny,
Operation: request.Operation,
Resource: request.Resource,
}
return &rule, nil
case OutcomeAllowOnce, OutcomeDenyOnce, OutcomeCancel:
return nil, nil
default:
return nil, fmt.Errorf("unsupported approval outcome %q", outcome)
}
}
+134
View File
@@ -0,0 +1,134 @@
package apiapproval
import (
"context"
"errors"
"testing"
"time"
"git.julianfamily.org/keepassgo/apitokens"
)
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
broker.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) }
resultCh := make(chan Result, 1)
errCh := make(chan error, 1)
go func() {
result, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI", ClientName: "grpc-cli"}, apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
resultCh <- result
errCh <- err
}()
waitForPending(t, broker, 1)
pending := broker.Pending()
if len(pending) != 1 {
t.Fatalf("Pending() len = %d, want 1", len(pending))
}
if pending[0].TokenID != "token-1" {
t.Fatalf("Pending()[0].TokenID = %q, want token-1", pending[0].TokenID)
}
if _, _, err := broker.Resolve(pending[0].ID, OutcomeAllowOnce); err != nil {
t.Fatalf("Resolve(allow once) error = %v", err)
}
result := <-resultCh
if err := <-errCh; err != nil {
t.Fatalf("Request() error = %v, want nil", err)
}
if result.Outcome != OutcomeAllowOnce {
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeAllowOnce)
}
if result.Rule != nil {
t.Fatalf("Request() rule = %#v, want nil for allow-once", result.Rule)
}
if got := broker.Pending(); len(got) != 0 {
t.Fatalf("Pending() after allow len = %d, want 0", len(got))
}
}
func TestBrokerReturnsPermanentRuleForDeny(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
reqDone := make(chan struct{})
var result Result
var err error
go func() {
result, err = broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationReadEntry, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1", Path: []string{"Root", "Internet"}})
close(reqDone)
}()
waitForPending(t, broker, 1)
pending := broker.Pending()[0]
request, rule, resolveErr := broker.Resolve(pending.ID, OutcomeDenyPermanent)
if resolveErr != nil {
t.Fatalf("Resolve(deny permanent) error = %v", resolveErr)
}
if request.ID != pending.ID {
t.Fatalf("Resolve().ID = %q, want %q", request.ID, pending.ID)
}
if rule == nil || rule.Effect != apitokens.EffectDeny || rule.Operation != apitokens.OperationReadEntry {
t.Fatalf("Resolve() rule = %#v, want deny read-entry rule", rule)
}
<-reqDone
if !errors.Is(err, ErrRequestDenied) {
t.Fatalf("Request() error = %v, want ErrRequestDenied", err)
}
if result.Rule == nil || result.Rule.Effect != apitokens.EffectDeny {
t.Fatalf("Request() rule = %#v, want deny rule", result.Rule)
}
if result.Outcome != OutcomeDenyPermanent {
t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeDenyPermanent)
}
}
func TestBrokerSupportsCancellation(t *testing.T) {
t.Parallel()
broker := NewBroker(time.Minute)
errCh := make(chan error, 1)
go func() {
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
errCh <- err
}()
waitForPending(t, broker, 1)
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeCancel); err != nil {
t.Fatalf("Resolve(cancel) error = %v", err)
}
if err := <-errCh; !errors.Is(err, ErrRequestCanceled) {
t.Fatalf("Request() error = %v, want ErrRequestCanceled", err)
}
}
func TestBrokerTimesOutPendingRequests(t *testing.T) {
t.Parallel()
broker := NewBroker(10 * time.Millisecond)
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
if !errors.Is(err, ErrRequestTimedOut) {
t.Fatalf("Request() error = %v, want ErrRequestTimedOut", err)
}
if got := broker.Pending(); len(got) != 0 {
t.Fatalf("Pending() len after timeout = %d, want 0", len(got))
}
}
func waitForPending(t *testing.T, broker *Broker, want int) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if got := len(broker.Pending()); got == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("Pending() never reached len %d", want)
}