diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 1df0508..a3bf0a6 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -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) } diff --git a/docs/access.md b/docs/access.md index 5dfced2..99b010a 100644 --- a/docs/access.md +++ b/docs/access.md @@ -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` без проброса в контейнер недостаточно. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b6643e4..3490676 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -13,7 +13,7 @@ info: **Аутентификация (dual):** - **API key** — `Authorization: Bearer ` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`). - - **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:
:`; tenant из `EVOBGP_PORTAL_TENANT_ID`. + - **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:
:`; 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`. diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 00f7acb..f605f7c 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -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: diff --git a/internal/httpapi/auth_jwt_test.go b/internal/httpapi/auth_jwt_test.go index 5df51eb..73a4b7c 100644 --- a/internal/httpapi/auth_jwt_test.go +++ b/internal/httpapi/auth_jwt_test.go @@ -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() diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 6e7ab4d..e36055b 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) }