Files
cozystack/internal/observabilityproxy/auth_test.go
T
ZverGuy bb073eec1b fix(observability-proxy): do not cache reviews that failed to decide
A TokenReview whose authenticator failed comes back unauthenticated
with status.error set, and a SubjectAccessReview whose authorizer
failed comes back with an evaluation error and no decision. Both were
cached as refusals, so a valid caller stayed locked out for the cache
TTL after the outage ended.

Such answers are now refused for the current request only. A review
that failed to decide still answers 401 or 403 rather than 503: RBAC
sets an evaluation error for any binding to a missing role, which a
retryable status would turn into a permanent 503 for that user.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-10 00:45:06 +07:00

221 lines
8.2 KiB
Go

/*
Copyright 2026 The Cozystack Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package observabilityproxy
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
authenticationv1 "k8s.io/api/authentication/v1"
authorizationv1 "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
k8stesting "k8s.io/client-go/testing"
)
// fakeKube answers TokenReviews for the tokens in users and allows a
// SubjectAccessReview when allow returns true; it counts both calls.
type fakeKube struct {
client *fake.Clientset
reviews atomic.Int32
sars atomic.Int32
lastSAR atomic.Pointer[authorizationv1.SubjectAccessReviewSpec]
reviewFn func() error
// reviewError and evaluationError, when set, are what the
// authenticator or the authorizer report as failing to decide.
reviewError atomic.Pointer[string]
evaluationError atomic.Pointer[string]
}
func newFakeKube(users map[string]string, allow func(user string, attrs *authorizationv1.ResourceAttributes) bool) *fakeKube {
f := &fakeKube{client: fake.NewClientset()}
f.client.PrependReactor("create", "tokenreviews", func(action k8stesting.Action) (bool, runtime.Object, error) {
f.reviews.Add(1)
if f.reviewFn != nil {
if err := f.reviewFn(); err != nil {
return true, nil, err
}
}
tr := action.(k8stesting.CreateAction).GetObject().(*authenticationv1.TokenReview).DeepCopy()
if e := f.reviewError.Load(); e != nil {
tr.Status = authenticationv1.TokenReviewStatus{Error: *e}
} else if name, ok := users[tr.Spec.Token]; ok {
tr.Status = authenticationv1.TokenReviewStatus{Authenticated: true, User: authenticationv1.UserInfo{Username: name, Groups: []string{"tenant-a-view"}}}
}
return true, tr, nil
})
f.client.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) {
f.sars.Add(1)
sar := action.(k8stesting.CreateAction).GetObject().(*authorizationv1.SubjectAccessReview).DeepCopy()
spec := sar.Spec
f.lastSAR.Store(&spec)
if e := f.evaluationError.Load(); e != nil {
sar.Status.EvaluationError = *e
return true, sar, nil
}
sar.Status.Allowed = allow(sar.Spec.User, sar.Spec.ResourceAttributes)
return true, sar, nil
})
return f
}
func allowNamespace(ns string) func(string, *authorizationv1.ResourceAttributes) bool {
return func(_ string, a *authorizationv1.ResourceAttributes) bool { return a != nil && a.Namespace == ns }
}
func TestAuthenticate(t *testing.T) {
kube := newFakeKube(map[string]string{"good": "alice"}, allowNamespace("tenant-a"))
a := NewAuthorizer(kube.client, 10*time.Second)
ctx := context.Background()
u, err := a.Authenticate(ctx, "good")
if err != nil || u.Username != "alice" {
t.Fatalf("Authenticate(good) = %+v, %v", u, err)
}
if _, err := a.Authenticate(ctx, "good"); err != nil {
t.Fatal(err)
}
if n := kube.reviews.Load(); n != 1 {
t.Fatalf("TokenReviews = %d, want 1 (second call cached)", n)
}
if _, err := a.Authenticate(ctx, "bad"); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("Authenticate(bad) err = %v, want ErrUnauthenticated", err)
}
if _, err := a.Authenticate(ctx, ""); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("Authenticate(\"\") err = %v, want ErrUnauthenticated", err)
}
}
func TestAuthenticateCacheExpires(t *testing.T) {
kube := newFakeKube(map[string]string{"good": "alice"}, allowNamespace("tenant-a"))
a := NewAuthorizer(kube.client, 10*time.Second)
now := time.Unix(1000, 0)
a.now = func() time.Time { return now }
ctx := context.Background()
if _, err := a.Authenticate(ctx, "good"); err != nil {
t.Fatal(err)
}
now = now.Add(11 * time.Second)
if _, err := a.Authenticate(ctx, "good"); err != nil {
t.Fatal(err)
}
if n := kube.reviews.Load(); n != 2 {
t.Fatalf("TokenReviews = %d, want 2 after TTL", n)
}
}
func TestAuthenticateErrorNotCached(t *testing.T) {
kube := newFakeKube(map[string]string{"good": "alice"}, allowNamespace("tenant-a"))
kube.reviewFn = func() error { return errors.New("apiserver down") }
a := NewAuthorizer(kube.client, 10*time.Second)
ctx := context.Background()
if _, err := a.Authenticate(ctx, "good"); err == nil || errors.Is(err, ErrUnauthenticated) {
t.Fatalf("err = %v, want a transport error", err)
}
kube.reviewFn = nil
if _, err := a.Authenticate(ctx, "good"); err != nil {
t.Fatalf("err = %v after recovery", err)
}
}
func TestAuthorize(t *testing.T) {
kube := newFakeKube(map[string]string{"good": "alice"}, allowNamespace("tenant-a"))
a := NewAuthorizer(kube.client, 10*time.Second)
ctx := context.Background()
user := &authenticationv1.UserInfo{Username: "alice", UID: "u1", Groups: []string{"g"}, Extra: map[string]authenticationv1.ExtraValue{"scope": {"x"}}}
allowed := Route{Kind: NamespaceLogs, Namespace: "tenant-a"}
denied := Route{Kind: NamespaceLogs, Namespace: "tenant-b"}
ok, err := a.Authorize(ctx, user, allowed.ResourceAttributes())
if err != nil || !ok {
t.Fatalf("allow: %v, %v", ok, err)
}
spec := kube.lastSAR.Load()
if spec.User != "alice" || spec.UID != "u1" || len(spec.Groups) != 1 || spec.Extra["scope"][0] != "x" {
t.Fatalf("SAR identity not forwarded: %+v", spec)
}
if ra := spec.ResourceAttributes; ra.Verb != "list" || ra.Group != "cozystack.io" || ra.Resource != "logs" || ra.Namespace != "tenant-a" {
t.Fatalf("SAR attributes = %+v", ra)
}
ok, err = a.Authorize(ctx, user, denied.ResourceAttributes())
if err != nil || ok {
t.Fatalf("deny: %v, %v", ok, err)
}
for range 3 {
if ok, _ := a.Authorize(ctx, user, allowed.ResourceAttributes()); !ok {
t.Fatal("cached allow flipped")
}
if ok, _ := a.Authorize(ctx, user, denied.ResourceAttributes()); ok {
t.Fatal("cached deny flipped")
}
}
if n := kube.sars.Load(); n != 2 {
t.Fatalf("SubjectAccessReviews = %d, want 2 (rest cached)", n)
}
other := &authenticationv1.UserInfo{Username: "alice", UID: "u1", Groups: []string{"g", "tenant-b-view"}}
if _, err := a.Authorize(ctx, other, denied.ResourceAttributes()); err != nil {
t.Fatal(err)
}
if n := kube.sars.Load(); n != 3 {
t.Fatalf("a different group set must not hit another identity's cache entry, SARs = %d", n)
}
}
// TestAuthenticateFailedReviewNotCached: a TokenReview the authenticator
// could not decide carries status.error; caching it would refuse a valid
// token for the TTL after the authenticator recovers.
func TestAuthenticateFailedReviewNotCached(t *testing.T) {
kube := newFakeKube(map[string]string{"good": "alice"}, allowNamespace("tenant-a"))
outage := "webhook authenticator unreachable"
kube.reviewError.Store(&outage)
a := NewAuthorizer(kube.client, 10*time.Second)
ctx := context.Background()
if _, err := a.Authenticate(ctx, "good"); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("err = %v, want ErrUnauthenticated", err)
}
kube.reviewError.Store(nil)
if u, err := a.Authenticate(ctx, "good"); err != nil || u.Username != "alice" {
t.Fatalf("after recovery: %+v, %v", u, err)
}
}
// TestAuthorizeEvaluationErrorNotCached: a SubjectAccessReview with an
// evaluation error and no decision is a denial for this request only.
func TestAuthorizeEvaluationErrorNotCached(t *testing.T) {
kube := newFakeKube(map[string]string{"good": "alice"}, allowNamespace("tenant-a"))
failure := "webhook authorizer unreachable"
kube.evaluationError.Store(&failure)
a := NewAuthorizer(kube.client, 10*time.Second)
ctx := context.Background()
user := &authenticationv1.UserInfo{Username: "alice"}
attrs := Route{Kind: NamespaceLogs, Namespace: "tenant-a"}.ResourceAttributes()
if ok, err := a.Authorize(ctx, user, attrs); err != nil || ok {
t.Fatalf("undecided review = %v, %v; want a denial", ok, err)
}
kube.evaluationError.Store(nil)
if ok, err := a.Authorize(ctx, user, attrs); err != nil || !ok {
t.Fatalf("after recovery: %v, %v", ok, err)
}
}