CI / changes (push) Successful in 4s
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 58s
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m15s
Updated the authentication logic to prioritize tenant resolution from JWT claims, specifically using `tenants.bgp` or `bgp_tenant_id` as the primary source. If these claims are absent, the system will fallback to the configured `EVOBGP_PORTAL_TENANT_ID`. This change improves the flexibility of tenant management in the authentication process. Additionally, updated related documentation to reflect these changes and added tests to ensure proper functionality.
303 lines
7.8 KiB
Go
303 lines
7.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
const (
|
|
testJWTSecret = "test-secret-32-bytes-long-abcdef"
|
|
testIssuer = "https://auth.test.local"
|
|
)
|
|
|
|
func signTestJWT(t *testing.T, claims jwt.MapClaims) string {
|
|
t.Helper()
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
s, err := tok.SignedString([]byte(testJWTSecret))
|
|
if err != nil {
|
|
t.Fatalf("sign jwt: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func newJWTTestServer(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
srv, err := New(Options{
|
|
SeedDemo: true,
|
|
BundleSeedHex: testBundleSeed,
|
|
JWTSecret: testJWTSecret,
|
|
AuthIssuer: testIssuer,
|
|
AuthPortalURL: "https://portal.test.local",
|
|
AuthRequired: true,
|
|
PortalTenantID: "", // filled after DemoIDs
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
|
// Override tenant to match seed.
|
|
srv.portalTenantID = tenant
|
|
return srv, tenant
|
|
}
|
|
|
|
func TestAuthJWTAcceptedWithBGPApp(t *testing.T) {
|
|
srv, _ := newJWTTestServer(t)
|
|
defer srv.Close()
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
token := signTestJWT(t, jwt.MapClaims{
|
|
"iss": testIssuer,
|
|
"sub": "user-1",
|
|
"email": "[email protected]",
|
|
"apps": []string{"bgp"},
|
|
"permissions": []string{"bgp:modules:read"},
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
|
}
|
|
}
|
|
|
|
func TestAuthJWTRejectedOnWrongIssuer(t *testing.T) {
|
|
srv, _ := newJWTTestServer(t)
|
|
defer srv.Close()
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
token := signTestJWT(t, jwt.MapClaims{
|
|
"iss": "https://other.example.com",
|
|
"sub": "user-1",
|
|
"apps": []string{"bgp"},
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) {
|
|
srv, _ := newJWTTestServer(t)
|
|
defer srv.Close()
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
token := signTestJWT(t, jwt.MapClaims{
|
|
"iss": testIssuer,
|
|
"sub": "user-1",
|
|
"apps": []string{"cfdm", "portal"},
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("status=%d want 403", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) {
|
|
srv, _ := newJWTTestServer(t)
|
|
defer srv.Close()
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
token := signTestJWT(t, jwt.MapClaims{
|
|
"iss": testIssuer,
|
|
"sub": "admin-1",
|
|
"apps": []string{"bgp"},
|
|
"is_admin": true,
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
|
}
|
|
}
|
|
|
|
func TestAuthJWTMissingPermissionRejected(t *testing.T) {
|
|
srv, _ := newJWTTestServer(t)
|
|
defer srv.Close()
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
token := signTestJWT(t, jwt.MapClaims{
|
|
"iss": testIssuer,
|
|
"sub": "user-1",
|
|
"apps": []string{"bgp"},
|
|
"permissions": []string{"bgp:modules:read"},
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("status=%d want 403", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAuthJWTTenantFromClaimWithoutEnv(t *testing.T) {
|
|
srv, err := New(Options{
|
|
SeedDemo: true,
|
|
BundleSeedHex: testBundleSeed,
|
|
JWTSecret: testJWTSecret,
|
|
AuthIssuer: testIssuer,
|
|
AuthPortalURL: "https://portal.test.local",
|
|
AuthRequired: true,
|
|
// No PortalTenantID — must come from JWT claim.
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer srv.Close()
|
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
token := signTestJWT(t, jwt.MapClaims{
|
|
"iss": testIssuer,
|
|
"sub": "user-1",
|
|
"apps": []string{"bgp"},
|
|
"permissions": []string{"bgp:modules:read"},
|
|
"bgp_tenant_id": tenant,
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
|
}
|
|
}
|
|
|
|
func TestAuthJWTRejectedWhenTenantMissing(t *testing.T) {
|
|
srv, err := New(Options{
|
|
SeedDemo: true,
|
|
BundleSeedHex: testBundleSeed,
|
|
JWTSecret: testJWTSecret,
|
|
AuthIssuer: testIssuer,
|
|
AuthPortalURL: "https://portal.test.local",
|
|
AuthRequired: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer srv.Close()
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
token := signTestJWT(t, jwt.MapClaims{
|
|
"iss": testIssuer,
|
|
"sub": "user-1",
|
|
"apps": []string{"bgp"},
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d want 503", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAuthConfigPublic(t *testing.T) {
|
|
srv, _ := newJWTTestServer(t)
|
|
defer srv.Close()
|
|
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil)
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
|
}
|
|
}
|
|
|
|
func TestHasPermissionSupersets(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
granted []string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true},
|
|
{"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true},
|
|
{"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true},
|
|
{"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false},
|
|
{"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false},
|
|
{"empty-granted", nil, "bgp:modules:read", false},
|
|
{"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := HasPermission(tc.granted, tc.want); got != tc.ok {
|
|
t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok)
|
|
}
|
|
})
|
|
}
|
|
}
|