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()
|
||||
if (!claims) return false
|
||||
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
||||
if (claims.is_admin) return true
|
||||
return hasPermission(claims.permissions, required)
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -12,7 +12,12 @@
|
||||
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
|
||||
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
|
||||
| `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` без проброса в контейнер недостаточно.
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ info:
|
||||
|
||||
**Аутентификация (dual):**
|
||||
- **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 }`.
|
||||
|
||||
**Роли 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.
|
||||
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
|
||||
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) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
@@ -190,12 +187,19 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
||||
if strings.TrimSpace(sub) == "" {
|
||||
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)
|
||||
perms := coerceStringSlice(claims["permissions"])
|
||||
isAdmin, _ := claims["is_admin"].(bool)
|
||||
return Auth{
|
||||
Kind: AuthKindJWT,
|
||||
TenantID: s.portalTenantID,
|
||||
TenantID: tenantID,
|
||||
UserID: strings.TrimSpace(sub),
|
||||
Email: strings.TrimSpace(email),
|
||||
Permissions: perms,
|
||||
@@ -204,6 +208,21 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
||||
}, 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 {
|
||||
switch t := v.(type) {
|
||||
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) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
@@ -61,7 +61,7 @@ type Options struct {
|
||||
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user