Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions async/async.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,52 @@ import (
// from the originating request so it survives handler return.
type Task func(ctx context.Context) error

// Runner bounds fire-and-forget work so degraded downstreams cannot create
// unbounded goroutines under load.
type Runner struct {
sem chan struct{}
logger *slog.Logger
}

// NewRunner creates a bounded background task runner.
func NewRunner(maxInFlight int, logger *slog.Logger) *Runner {
if maxInFlight <= 0 {
maxInFlight = 1
}
if logger == nil {
logger = slog.Default()
}
return &Runner{sem: make(chan struct{}, maxInFlight), logger: logger}
}

// TryGo launches task with a context detached from request cancellation when
// capacity is available. It returns false without launching a goroutine when
// the runner is full.
func (r *Runner) TryGo(ctx context.Context, op string, task Task) bool {
if r == nil || task == nil {
return false
}
select {
case r.sem <- struct{}{}:
default:
r.logger.Warn("background task rejected: capacity exhausted", "op", op)
return false
}
bgCtx := context.WithoutCancel(ctx)
go func() {
defer func() {
if recovered := recover(); recovered != nil {
r.logger.Error("background task panicked", "op", op, "panic", recovered, "stack", string(debug.Stack()))
}
<-r.sem
}()
if err := task(bgCtx); err != nil {
r.logger.Warn("background task failed", "op", op, "error", err)
}
}()
return true
}

// FireAndForget launches task in a background goroutine with a context
// detached from the parent (via context.WithoutCancel). Errors are logged
// at WARN level and panics are recovered and logged at ERROR level with the
Expand Down
27 changes: 27 additions & 0 deletions async/async_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,33 @@ func TestFireAndForgetRecoversPanics(t *testing.T) {
}
}

func TestRunnerRejectsWhenFull(t *testing.T) {
runner := async.NewRunner(1, slog.Default())
started := make(chan struct{})
release := make(chan struct{})

if !runner.TryGo(context.Background(), "test.block", func(_ context.Context) error {
close(started)
<-release
return nil
}) {
t.Fatal("expected first task to be accepted")
}
<-started

var ran atomic.Bool
if runner.TryGo(context.Background(), "test.rejected", func(_ context.Context) error {
ran.Store(true)
return nil
}) {
t.Fatal("expected second task to be rejected while runner is full")
}
if ran.Load() {
t.Fatal("rejected task should not run")
}
close(release)
}

func TestCloneProtoPreventsMutationRace(t *testing.T) {
original := &approvalsv1.ApprovalRequest{
Id: "req-1",
Expand Down
38 changes: 19 additions & 19 deletions authmw/authmw.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package authmw
import (
"context"
"net/http"
"slices"
"strings"

"github.com/evalops/service-runtime/httpkit"
Expand Down Expand Up @@ -76,7 +77,7 @@ func (middleware *Middleware) WithAuth(scopes ...string) func(http.Handler) http
return
}

actor, err := middleware.authenticate(request.Context(), token, scopes)
actor, availableScopes, err := middleware.authenticate(request.Context(), token, scopes)
if err != nil {
status := http.StatusUnauthorized
if middleware.isForbidden(err) {
Expand All @@ -87,6 +88,7 @@ func (middleware *Middleware) WithAuth(scopes ...string) func(http.Handler) http
}

ctx := context.WithValue(request.Context(), actorContextKey, actor)
ctx = ContextWithPrincipal(ctx, PrincipalFromActor(actor, availableScopes))
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
Expand All @@ -101,20 +103,18 @@ func ActorFromContext(ctx context.Context) (Actor, bool) {
// HasAllScopes reports whether all required scopes are present in available.
func HasAllScopes(available []string, required []string) bool {
for _, requirement := range required {
found := false
for _, scope := range available {
if scope == requirement {
found = true
break
}
}
if !found {
if !HasScope(available, requirement) {
return false
}
}
return true
}

// HasScope reports whether the required scope is present in available scopes.
func HasScope(available []string, required string) bool {
return required != "" && slices.Contains(available, required)
}

// BearerToken extracts the token from an Authorization: Bearer <token> header.
func BearerToken(header string) (string, bool) {
parts := strings.SplitN(header, " ", 2)
Expand All @@ -125,41 +125,41 @@ func BearerToken(header string) (string, bool) {
return token, token != ""
}

func (middleware *Middleware) authenticate(ctx context.Context, token string, scopes []string) (Actor, error) {
func (middleware *Middleware) authenticate(ctx context.Context, token string, scopes []string) (Actor, []string, error) {
if strings.HasPrefix(token, "pk_") {
return middleware.authenticateAPIKey(ctx, token, scopes)
}
return middleware.authenticateToken(ctx, token, scopes)
}

func (middleware *Middleware) authenticateAPIKey(ctx context.Context, token string, scopes []string) (Actor, error) {
func (middleware *Middleware) authenticateAPIKey(ctx context.Context, token string, scopes []string) (Actor, []string, error) {
if middleware == nil || middleware.apiKeyValidator == nil {
return Actor{}, errAPIKeyValidatorUnavailable
return Actor{}, nil, errAPIKeyValidatorUnavailable
}
key, err := middleware.apiKeyValidator.ValidateAPIKey(ctx, token)
if err != nil {
return Actor{}, err
return Actor{}, nil, err
}
if !HasAllScopes(key.Scopes, scopes) {
return Actor{}, errMissingScopes
return Actor{}, nil, errMissingScopes
}

return Actor{
Type: "api_key",
ID: key.ID,
OrganizationID: key.OrganizationID,
}, nil
}, append([]string(nil), key.Scopes...), nil
}

func (middleware *Middleware) authenticateToken(ctx context.Context, token string, scopes []string) (Actor, error) {
func (middleware *Middleware) authenticateToken(ctx context.Context, token string, scopes []string) (Actor, []string, error) {
if middleware == nil || middleware.tokenVerifier == nil {
return Actor{}, errTokenVerifierUnavailable
return Actor{}, nil, errTokenVerifierUnavailable
}
verified, err := middleware.tokenVerifier.VerifyToken(ctx, token, scopes)
if err != nil {
return Actor{}, err
return Actor{}, nil, err
}
return verified.Actor, nil
return verified.Actor, append([]string(nil), verified.Scopes...), nil
}

func (middleware *Middleware) isForbidden(err error) bool {
Expand Down
23 changes: 23 additions & 0 deletions authmw/authmw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,17 @@ func TestWithAuthAPIKey(t *testing.T) {
middleware := New(Config{APIKeyValidator: validator})

var seenActor Actor
var seenPrincipal Principal
handler := middleware.WithAuth("scope:read")(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
var ok bool
seenActor, ok = ActorFromContext(request.Context())
if !ok {
t.Fatal("expected actor in context")
}
seenPrincipal, ok = PrincipalFromContext(request.Context())
if !ok {
t.Fatal("expected principal in context")
}
writer.WriteHeader(http.StatusNoContent)
}))

Expand All @@ -60,6 +65,12 @@ func TestWithAuthAPIKey(t *testing.T) {
if seenActor.Type != "api_key" || seenActor.OrganizationID != "org-123" {
t.Fatalf("unexpected actor %#v", seenActor)
}
if seenPrincipal.OrganizationID != "org-123" || seenPrincipal.Subject != "integration-key" {
t.Fatalf("unexpected principal %#v", seenPrincipal)
}
if err := seenPrincipal.RequireScope("scope:read"); err != nil {
t.Fatalf("expected scope to be present: %v", err)
}
}

func TestWithAuthAPIKeyMissingScopes(t *testing.T) {
Expand Down Expand Up @@ -118,17 +129,23 @@ func TestWithAuthServiceToken(t *testing.T) {
ID: "pipeline",
OrganizationID: "org-123",
},
Scopes: []string{"scope:write"},
},
},
})

var seenActor Actor
var seenPrincipal Principal
handler := middleware.WithAuth("scope:write")(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
var ok bool
seenActor, ok = ActorFromContext(request.Context())
if !ok {
t.Fatal("expected actor in context")
}
seenPrincipal, ok = PrincipalFromContext(request.Context())
if !ok {
t.Fatal("expected principal in context")
}
writer.WriteHeader(http.StatusAccepted)
}))

Expand All @@ -143,6 +160,12 @@ func TestWithAuthServiceToken(t *testing.T) {
if seenActor.Type != "service" || seenActor.ID != "pipeline" {
t.Fatalf("unexpected actor %#v", seenActor)
}
if seenPrincipal.Service != "pipeline" || seenPrincipal.OrganizationID != "org-123" {
t.Fatalf("unexpected principal %#v", seenPrincipal)
}
if err := seenPrincipal.RequireScope("scope:write"); err != nil {
t.Fatalf("expected scope to be present: %v", err)
}
}

func TestWithAuthServiceTokenWithoutVerifier(t *testing.T) {
Expand Down
Loading