feat(auth): enhance tenant resolution in JWT handling
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
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.
This commit is contained in:
@@ -255,6 +255,7 @@ export function can(required: string): boolean {
|
|||||||
const claims = getClaims()
|
const claims = getClaims()
|
||||||
if (!claims) return false
|
if (!claims) return false
|
||||||
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
||||||
|
if (claims.is_admin) return true
|
||||||
return hasPermission(claims.permissions, required)
|
return hasPermission(claims.permissions, required)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -12,7 +12,12 @@
|
|||||||
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
|
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
|
||||||
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
|
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
|
||||||
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
|
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
|
||||||
| `EVOBGP_PORTAL_TENANT_ID` | Tenant для всех portal JWT (обязателен при JWT) |
|
| `EVOBGP_PORTAL_TENANT_ID` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
|
||||||
|
|
||||||
|
Источник tenant (по приоритету):
|
||||||
|
|
||||||
|
1. JWT claim `tenants.bgp` или `bgp_tenant_id` (задаётся в auth-portal → **Админ → Приложения** → поле «EvoBGP tenant ID»)
|
||||||
|
2. Env `EVOBGP_PORTAL_TENANT_ID`
|
||||||
|
|
||||||
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
|
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ info:
|
|||||||
|
|
||||||
**Аутентификация (dual):**
|
**Аутентификация (dual):**
|
||||||
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
|
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
|
||||||
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `EVOBGP_PORTAL_TENANT_ID`.
|
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `tenants.bgp` / `bgp_tenant_id` или fallback `EVOBGP_PORTAL_TENANT_ID`.
|
||||||
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
|
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
|
||||||
|
|
||||||
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
|
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
|
||||||
|
|||||||
@@ -160,9 +160,6 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
|||||||
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
|
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
|
||||||
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
|
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
|
||||||
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
||||||
if strings.TrimSpace(s.portalTenantID) == "" {
|
|
||||||
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false
|
|
||||||
}
|
|
||||||
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, jwt.ErrSignatureInvalid
|
return nil, jwt.ErrSignatureInvalid
|
||||||
@@ -190,12 +187,19 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
|||||||
if strings.TrimSpace(sub) == "" {
|
if strings.TrimSpace(sub) == "" {
|
||||||
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
|
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
|
||||||
}
|
}
|
||||||
|
tenantID := tenantIDFromClaims(claims)
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = strings.TrimSpace(s.portalTenantID)
|
||||||
|
}
|
||||||
|
if tenantID == "" {
|
||||||
|
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (set bgp tenant in auth-portal App Switcher or EVOBGP_PORTAL_TENANT_ID)", false
|
||||||
|
}
|
||||||
email, _ := claims["email"].(string)
|
email, _ := claims["email"].(string)
|
||||||
perms := coerceStringSlice(claims["permissions"])
|
perms := coerceStringSlice(claims["permissions"])
|
||||||
isAdmin, _ := claims["is_admin"].(bool)
|
isAdmin, _ := claims["is_admin"].(bool)
|
||||||
return Auth{
|
return Auth{
|
||||||
Kind: AuthKindJWT,
|
Kind: AuthKindJWT,
|
||||||
TenantID: s.portalTenantID,
|
TenantID: tenantID,
|
||||||
UserID: strings.TrimSpace(sub),
|
UserID: strings.TrimSpace(sub),
|
||||||
Email: strings.TrimSpace(email),
|
Email: strings.TrimSpace(email),
|
||||||
Permissions: perms,
|
Permissions: perms,
|
||||||
@@ -204,6 +208,21 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
|||||||
}, 0, "", true
|
}, 0, "", true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tenantIDFromClaims prefers tenants.bgp, then bgp_tenant_id.
|
||||||
|
func tenantIDFromClaims(claims jwt.MapClaims) string {
|
||||||
|
if m, ok := claims["tenants"].(map[string]any); ok {
|
||||||
|
if v, ok := m["bgp"].(string); ok {
|
||||||
|
if tid := strings.TrimSpace(v); tid != "" {
|
||||||
|
return tid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := claims["bgp_tenant_id"].(string); ok {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func coerceStringSlice(v any) []string {
|
func coerceStringSlice(v any) []string {
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case []string:
|
case []string:
|
||||||
|
|||||||
@@ -181,6 +181,83 @@ func TestAuthJWTMissingPermissionRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestAuthConfigPublic(t *testing.T) {
|
||||||
srv, _ := newJWTTestServer(t)
|
srv, _ := newJWTTestServer(t)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ type Options struct {
|
|||||||
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
|
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
|
||||||
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
|
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
|
||||||
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
|
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
|
||||||
PortalTenantID string // EVOBGP_PORTAL_TENANT_ID (single tenant scope for JWT users)
|
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
|
||||||
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
|
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user