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) } }