fix: restore jwt login and control-plane checks
This commit is contained in:
@@ -9,6 +9,9 @@ kind: ClusterRole
|
|||||||
metadata:
|
metadata:
|
||||||
name: fission-console
|
name: fission-console
|
||||||
rules:
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["get", "list"]
|
||||||
- apiGroups: ["fission.io"]
|
- apiGroups: ["fission.io"]
|
||||||
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
||||||
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||||
@@ -46,7 +49,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v0.8.16
|
image: naeel/fission-console@sha256:765fecf927e64ca50fffbb1bf50169ab318ab7bb2e0878493dd2b3aaf11989c3
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -37,37 +37,32 @@ func (s *Server) userNS(r *http.Request) string {
|
|||||||
func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var ns string
|
var ns string
|
||||||
|
env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env")))
|
||||||
|
if _, ok := deckAPIs[env]; !ok {
|
||||||
|
env = "test"
|
||||||
|
}
|
||||||
|
|
||||||
if s.testMode {
|
if s.testMode {
|
||||||
sub := strings.TrimSpace(r.Header.Get("X-Test-Sub"))
|
sub := strings.TrimSpace(r.Header.Get("X-Test-Sub"))
|
||||||
if sub == "" {
|
if sub != "" {
|
||||||
sub = strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
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 {
|
} else {
|
||||||
token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
||||||
env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env")))
|
resolvedNS, err := s.resolveNamespaceForToken(token, env, false)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ns = s.ns
|
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
ns = resolvedNS
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), ctxKeyNS{}, ns)
|
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 (без верификации подписи),
|
// namespaceFromJWT декодирует JWT payload (без верификации подписи),
|
||||||
// извлекает claim "sub" и вычисляет namespace: "fission-" + hex(SHA256(sub)[:8]).
|
// извлекает claim "sub" и вычисляет namespace: "fission-" + hex(SHA256(sub)[:8]).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -659,26 +658,10 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
env = "test"
|
env = "test"
|
||||||
}
|
}
|
||||||
|
|
||||||
var ns string
|
ns, err := s.resolveNamespaceForToken(body.Token, env, s.testMode)
|
||||||
if s.testMode {
|
if err != nil {
|
||||||
// testMode: токен — это email (sub), Deck не вызывается
|
writeJSONError(w, http.StatusUnauthorized, "invalid token")
|
||||||
if !strings.Contains(body.Token, "@") {
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["namespaces", "serviceaccounts", "resourcequotas", "limitranges"]
|
resources: ["namespaces", "serviceaccounts", "resourcequotas", "limitranges"]
|
||||||
verbs: ["get", "create", "list"]
|
verbs: ["get", "create", "list"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["get", "list"]
|
||||||
- apiGroups: ["fission.io"]
|
- apiGroups: ["fission.io"]
|
||||||
resources: ["*"]
|
resources: ["*"]
|
||||||
verbs: ["*"]
|
verbs: ["*"]
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ auth_call() {
|
|||||||
-d "{\"token\":\"${token}\",\"env\":\"${env_name}\"}"
|
-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() {
|
create_call() {
|
||||||
local sub="$1"
|
local sub="$1"
|
||||||
local body="$2"
|
local body="$2"
|
||||||
@@ -112,6 +121,67 @@ wait_invoke_ok() {
|
|||||||
return 1
|
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 "========================================"
|
echo "========================================"
|
||||||
echo " UI-like user flow tests"
|
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"
|
[ "$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}"
|
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 ""
|
||||||
echo ">>> T03: ошибки create, которые типичны для UI"
|
echo ">>> T03: ошибки create, которые типичны для UI"
|
||||||
R=$(create_call "$NEG_SUB" '{"name":"","language":"python","code":"def main():\n return 1"}')
|
R=$(create_call "$NEG_SUB" '{"name":"","language":"python","code":"def main():\n return 1"}')
|
||||||
|
|||||||
Reference in New Issue
Block a user