158 lines
5 KiB
Go
158 lines
5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
"github.com/grafana/grafana-plugin-sdk-go/config"
|
|
)
|
|
|
|
type authFixture struct {
|
|
key *ecdsa.PrivateKey
|
|
server *httptest.Server
|
|
admin atomic.Bool
|
|
status atomic.Int32
|
|
badJSON atomic.Bool
|
|
keyCalls atomic.Int32
|
|
userCalls atomic.Int32
|
|
bearer atomic.Value
|
|
}
|
|
|
|
func newAuthFixture(t *testing.T) *authFixture {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &authFixture{key: key}
|
|
f.admin.Store(true)
|
|
f.status.Store(http.StatusOK)
|
|
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
switch req.URL.Path {
|
|
case "/api/signing-keys/keys":
|
|
f.keyCalls.Add(1)
|
|
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "synthetic"}}})
|
|
case "/api/users/17":
|
|
f.userCalls.Add(1)
|
|
f.bearer.Store(req.Header.Get("Authorization"))
|
|
w.WriteHeader(int(f.status.Load()))
|
|
if f.badJSON.Load() {
|
|
_, _ = w.Write([]byte("invalid JSON"))
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"isGrafanaAdmin": f.admin.Load(), "role": "Admin"})
|
|
default:
|
|
http.NotFound(w, req)
|
|
}
|
|
}))
|
|
t.Cleanup(f.server.Close)
|
|
return f
|
|
}
|
|
|
|
func signedID(t *testing.T, key *ecdsa.PrivateKey, subject string, expires time.Time) string {
|
|
t.Helper()
|
|
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jose.JSONWebKey{Key: key, KeyID: "synthetic"}}, new(jose.SignerOptions).WithType("jwt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, err := jwt.Signed(signer).Claims(jwt.Claims{Subject: subject, Expiry: jwt.NewNumericDate(expires)}).Serialize()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
func authRequest(identity, secret string) *http.Request {
|
|
ctx := config.WithGrafanaConfig(context.Background(), config.NewGrafanaCfg(map[string]string{config.AppClientSecret: secret}))
|
|
req := httptest.NewRequest("POST", "/command", strings.NewReader(`{"command":"list"}`)).WithContext(ctx)
|
|
req.Header.Set("X-Grafana-Id", identity)
|
|
req.Header.Set("X-Grafana-User", "forged-admin")
|
|
req.Header.Set("X-Grafana-Role", "Admin")
|
|
return req
|
|
}
|
|
|
|
func TestRoleRevocation(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
auth := newAuthorizer(f.server.URL)
|
|
identity := signedID(t, f.key, "user:17", time.Now().Add(time.Minute))
|
|
calls := 0
|
|
c := &console{allowed: auth.canSubmit, execute: func(context.Context, string) (string, error) { calls++; return "ok", nil }}
|
|
for _, step := range []struct {
|
|
admin bool
|
|
secret string
|
|
status int
|
|
}{
|
|
{true, "synthetic-first", 200},
|
|
{false, "synthetic-second", 403},
|
|
{false, "synthetic-second", 403},
|
|
{true, "synthetic-second", 200},
|
|
} {
|
|
f.admin.Store(step.admin)
|
|
w := httptest.NewRecorder()
|
|
c.routes().ServeHTTP(w, authRequest(identity, step.secret))
|
|
if w.Code != step.status {
|
|
t.Fatalf("status=%d want=%d", w.Code, step.status)
|
|
}
|
|
}
|
|
if calls != 2 || f.keyCalls.Load() != 1 || f.userCalls.Load() != 4 || f.bearer.Load() != "Bearer synthetic-second" {
|
|
t.Fatalf("commands=%d keys=%d users=%d bearer=%v", calls, f.keyCalls.Load(), f.userCalls.Load(), f.bearer.Load())
|
|
}
|
|
}
|
|
|
|
func TestIdentityFailures(t *testing.T) {
|
|
for _, name := range []string{"missing ID", "forged ID", "expired ID", "wrong signature", "service account", "empty user", "missing secret", "user API failure", "invalid profile", "organization admin"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newAuthFixture(t)
|
|
identity := signedID(t, f.key, "user:17", time.Now().Add(time.Minute))
|
|
secret := "synthetic-service"
|
|
status := http.StatusServiceUnavailable
|
|
switch name {
|
|
case "missing ID":
|
|
identity = ""
|
|
case "forged ID":
|
|
identity = "admin"
|
|
case "expired ID":
|
|
identity = signedID(t, f.key, "user:17", time.Now().Add(-time.Hour))
|
|
case "wrong signature":
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
identity = signedID(t, key, "user:17", time.Now().Add(time.Minute))
|
|
case "service account":
|
|
identity = signedID(t, f.key, "service-account:17", time.Now().Add(time.Minute))
|
|
status = http.StatusForbidden
|
|
case "empty user":
|
|
identity = signedID(t, f.key, "user:", time.Now().Add(time.Minute))
|
|
status = http.StatusForbidden
|
|
case "missing secret":
|
|
secret = ""
|
|
case "user API failure":
|
|
f.status.Store(http.StatusInternalServerError)
|
|
case "invalid profile":
|
|
f.badJSON.Store(true)
|
|
case "organization admin":
|
|
f.admin.Store(false)
|
|
status = http.StatusForbidden
|
|
}
|
|
calls := 0
|
|
c := &console{allowed: newAuthorizer(f.server.URL).canSubmit, execute: func(context.Context, string) (string, error) { calls++; return "ok", nil }}
|
|
w := httptest.NewRecorder()
|
|
c.routes().ServeHTTP(w, authRequest(identity, secret))
|
|
if w.Code != status || calls != 0 {
|
|
t.Fatalf("status=%d want=%d commands=%d", w.Code, status, calls)
|
|
}
|
|
})
|
|
}
|
|
}
|