diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index a7b5cf7..d8ceb70 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -9,6 +9,9 @@ kind: ClusterRole metadata: name: fission-console rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] - apiGroups: ["fission.io"] resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"] verbs: ["get", "list", "create", "update", "patch", "delete"] @@ -46,7 +49,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v0.8.16 + image: naeel/fission-console@sha256:765fecf927e64ca50fffbb1bf50169ab318ab7bb2e0878493dd2b3aaf11989c3 ports: - containerPort: 8090 env: diff --git a/console/internal/api/auth.go b/console/internal/api/auth.go index 2186234..773a3a1 100644 --- a/console/internal/api/auth.go +++ b/console/internal/api/auth.go @@ -37,37 +37,32 @@ func (s *Server) userNS(r *http.Request) string { func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var ns string + env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env"))) + if _, ok := deckAPIs[env]; !ok { + env = "test" + } if s.testMode { sub := strings.TrimSpace(r.Header.Get("X-Test-Sub")) - if sub == "" { - sub = strings.TrimSpace(r.Header.Get("X-Auth-Token")) + if sub != "" { + ns = namespaceFromSub(sub) + } else { + token := strings.TrimSpace(r.Header.Get("X-Auth-Token")) + resolvedNS, err := s.resolveNamespaceForToken(token, env, true) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return + } + ns = resolvedNS } - if sub == "" { - writeJSONError(w, http.StatusUnauthorized, "test mode: X-Test-Sub required") - return - } - h32 := sha256.Sum256([]byte(sub)) - ns = "fission-" + hex.EncodeToString(h32[:8]) } else { token := strings.TrimSpace(r.Header.Get("X-Auth-Token")) - env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env"))) - if _, ok := deckAPIs[env]; !ok { - env = "test" - } - if token == "" { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") - return - } - if err := s.validateDeckToken(token, env); err != nil { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") - return - } - var err error - ns, err = namespaceFromJWT(token) + resolvedNS, err := s.resolveNamespaceForToken(token, env, false) if err != nil { - ns = s.ns + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return } + ns = resolvedNS } ctx := context.WithValue(r.Context(), ctxKeyNS{}, ns) @@ -83,6 +78,35 @@ func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc { } } +func namespaceFromSub(sub string) string { + h32 := sha256.Sum256([]byte(sub)) + return "fission-" + hex.EncodeToString(h32[:8]) +} + +func (s *Server) resolveNamespaceForToken(token, env string, allowTestSub bool) (string, error) { + token = strings.TrimSpace(token) + if token == "" { + if allowTestSub { + return "", fmt.Errorf("test mode: X-Test-Sub required") + } + return "", fmt.Errorf("unauthorized") + } + + if err := s.validateDeckToken(token, env); err == nil { + ns, nsErr := namespaceFromJWT(token) + if nsErr != nil { + return s.ns, nil + } + return ns, nil + } + + if allowTestSub && strings.Contains(token, "@") { + return namespaceFromSub(token), nil + } + + return "", fmt.Errorf("invalid token") +} + // namespaceFromJWT декодирует JWT payload (без верификации подписи), // извлекает claim "sub" и вычисляет namespace: "fission-" + hex(SHA256(sub)[:8]). // diff --git a/console/internal/api/auth_test.go b/console/internal/api/auth_test.go new file mode 100644 index 0000000..5d40e4f --- /dev/null +++ b/console/internal/api/auth_test.go @@ -0,0 +1,112 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const validJWTForTests = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItMTIzIn0.signature" + +func TestResolveNamespaceForTokenRejectsInvalidTokenOutsideTestMode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/index.cfm/instances" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + oldDeckAPIs := deckAPIs + deckAPIs = map[string]string{"test": server.URL} + defer func() { deckAPIs = oldDeckAPIs }() + + s := &Server{http: server.Client()} + if _, err := s.resolveNamespaceForToken("user@example.com", "test", false); err == nil { + t.Fatal("expected invalid token to be rejected outside test mode") + } +} + +func TestResolveNamespaceForTokenAllowsValidatedJWTInTestMode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got == "" { + t.Fatal("expected Authorization header") + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer server.Close() + + oldDeckAPIs := deckAPIs + deckAPIs = map[string]string{"test": server.URL} + defer func() { deckAPIs = oldDeckAPIs }() + + s := &Server{http: server.Client()} + ns, err := s.resolveNamespaceForToken(validJWTForTests, "test", true) + if err != nil { + t.Fatalf("expected JWT to pass in test mode: %v", err) + } + + expected, err := namespaceFromJWT(validJWTForTests) + if err != nil { + t.Fatalf("namespaceFromJWT: %v", err) + } + if ns != expected { + t.Fatalf("expected namespace %q, got %q", expected, ns) + } +} + +func TestResolveNamespaceForTokenAllowsEmailFallbackOnlyInTestMode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + oldDeckAPIs := deckAPIs + deckAPIs = map[string]string{"test": server.URL} + defer func() { deckAPIs = oldDeckAPIs }() + + s := &Server{http: server.Client()} + ns, err := s.resolveNamespaceForToken("user@example.com", "test", true) + if err != nil { + t.Fatalf("expected email fallback in test mode: %v", err) + } + if ns != namespaceFromSub("user@example.com") { + t.Fatalf("unexpected namespace: %q", ns) + } + + _, err = s.resolveNamespaceForToken("user@example.com", "test", false) + if err == nil { + t.Fatal("expected email fallback to be rejected outside test mode") + } +} + +func TestValidateDeckTokenCachesSuccessfulValidation(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer server.Close() + + oldDeckAPIs := deckAPIs + deckAPIs = map[string]string{"test": server.URL} + defer func() { deckAPIs = oldDeckAPIs }() + + s := &Server{http: server.Client()} + if err := s.validateDeckToken(validJWTForTests, "test"); err != nil { + t.Fatalf("first validate failed: %v", err) + } + if err := s.validateDeckToken(validJWTForTests, "test"); err != nil { + t.Fatalf("second validate failed: %v", err) + } + if requests != 1 { + t.Fatalf("expected 1 upstream request because of cache, got %d", requests) + } + + if v, ok := s.tokenCache.Load("test:" + validJWTForTests); !ok || time.Now().After(v.(time.Time)) { + t.Fatal("expected token to be cached") + } +} \ No newline at end of file diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index 50706fa..5a9657f 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -3,7 +3,6 @@ package api import ( "bytes" "context" - "crypto/sha256" "encoding/base64" "encoding/json" "errors" @@ -659,26 +658,10 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { env = "test" } - var ns string - if s.testMode { - // testMode: токен — это email (sub), Deck не вызывается - if !strings.Contains(body.Token, "@") { - writeJSONError(w, http.StatusUnauthorized, "invalid token") - return - } - h32 := sha256.Sum256([]byte(body.Token)) - ns = "fission-" + fmt.Sprintf("%x", h32[:8]) - } else { - if err := s.validateDeckToken(body.Token, env); err != nil { - writeJSONError(w, http.StatusUnauthorized, "invalid token") - return - } - var err error - ns, err = namespaceFromJWT(body.Token) - if err != nil { - log.Printf("handleAuth: namespaceFromJWT: %v", err) - ns = s.ns - } + ns, err := s.resolveNamespaceForToken(body.Token, env, s.testMode) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "invalid token") + return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) diff --git a/deploy/rbac/console-ns-manager.yaml b/deploy/rbac/console-ns-manager.yaml index 8ec38cd..6491240 100644 --- a/deploy/rbac/console-ns-manager.yaml +++ b/deploy/rbac/console-ns-manager.yaml @@ -6,6 +6,9 @@ rules: - apiGroups: [""] resources: ["namespaces", "serviceaccounts", "resourcequotas", "limitranges"] verbs: ["get", "create", "list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] - apiGroups: ["fission.io"] resources: ["*"] verbs: ["*"] diff --git a/test_ui_user_flows.sh b/test_ui_user_flows.sh index 458fd26..ca43489 100755 --- a/test_ui_user_flows.sh +++ b/test_ui_user_flows.sh @@ -51,6 +51,15 @@ auth_call() { -d "{\"token\":\"${token}\",\"env\":\"${env_name}\"}" } +auth_get() { + local path="$1" + local token="$2" + local env_name="${3:-test}" + curl -s -w "\n%{http_code}" --max-time 45 "${BASE}${path}" \ + -H "X-Auth-Token: ${token}" \ + -H "X-Auth-Env: ${env_name}" +} + create_call() { local sub="$1" local body="$2" @@ -112,6 +121,67 @@ wait_invoke_ok() { return 1 } +wait_init_ready() { + local token="$1" + local env_name="${2:-test}" + local tries="$3" + local delay="$4" + local try debug_raw debug_code debug_body status_raw status_code status_body ready done dbg_ready exec_ready exec_pods router_ready router_pods + for try in $(seq 1 "$tries"); do + debug_raw=$(auth_get "/ns/debug" "$token" "$env_name") + debug_code=$(printf '%s' "$debug_raw" | tail -1) + debug_body=$(printf '%s' "$debug_raw" | sed '$d') + status_raw=$(auth_get "/ns/status" "$token" "$env_name") + status_code=$(printf '%s' "$status_raw" | tail -1) + status_body=$(printf '%s' "$status_raw" | sed '$d') + ready=$(json_field "$status_body" ready) + done=$(printf '%s' "$status_body" | python3 -c 'import json,sys +try: + data=json.load(sys.stdin) + print(sum(1 for s in data.get("stages", []) if s.get("done"))) +except Exception: + print(0)') + dbg_ready=$(printf '%s' "$debug_body" | python3 -c 'import json,sys +try: + data=json.load(sys.stdin) + print(str(data.get("controlPlane", {}).get("ready", ""))) +except Exception: + print("")') + exec_ready=$(printf '%s' "$debug_body" | python3 -c 'import json,sys +try: + data=json.load(sys.stdin) + print(str(data.get("controlPlane", {}).get("executor", {}).get("ready", ""))) +except Exception: + print("")') + exec_pods=$(printf '%s' "$debug_body" | python3 -c 'import json,sys +try: + data=json.load(sys.stdin) + print(data.get("controlPlane", {}).get("executor", {}).get("podCount", "")) +except Exception: + print("")') + router_ready=$(printf '%s' "$debug_body" | python3 -c 'import json,sys +try: + data=json.load(sys.stdin) + print(str(data.get("controlPlane", {}).get("router", {}).get("ready", ""))) +except Exception: + print("")') + router_pods=$(printf '%s' "$debug_body" | python3 -c 'import json,sys +try: + data=json.load(sys.stdin) + print(data.get("controlPlane", {}).get("router", {}).get("podCount", "")) +except Exception: + print("")') + echo " INIT_TRY=${try} status_http=${status_code} debug_http=${debug_code} ready=${ready} done=${done}/3 cp_ready=${dbg_ready} executor_ready=${exec_ready} executor_pods=${exec_pods} router_ready=${router_ready} router_pods=${router_pods}" + if [ "$status_code" = "200" ] && [ "$debug_code" = "200" ] && [ "$ready" = "True" -o "$ready" = "true" ]; then + return 0 + fi + if [ "$try" -lt "$tries" ]; then + sleep "$delay" + fi + done + return 1 +} + echo "" echo "========================================" echo " UI-like user flow tests" @@ -133,6 +203,14 @@ NS=$(json_field "$BODY" namespace) [ "$CODE" = "200" ] && pass "login valid token -> 200" || fail "login valid token -> got $CODE" printf '%s' "$NS" | grep -q '^fission-' && pass "login returns namespace" || fail "login namespace malformed: ${NS}" +echo "" +echo ">>> T02b: первый вход ждёт init overlay до 3/3" +if wait_init_ready "$LOGIN_SUB" "test" 12 5; then + pass "init overlay path -> namespace ready 3/3" +else + fail "init overlay path did not reach 3/3" +fi + echo "" echo ">>> T03: ошибки create, которые типичны для UI" R=$(create_call "$NEG_SUB" '{"name":"","language":"python","code":"def main():\n return 1"}')