diff --git a/console/main.go b/console/main.go index 54d2ccf..a16fa5e 100644 --- a/console/main.go +++ b/console/main.go @@ -59,6 +59,7 @@ type server struct { http *http.Client saTokenPath string invokeTimeout time.Duration + testMode bool // FISSION_TEST_MODE=true — пропускает deck auth, X-Test-Sub задаёт user authUser string authPass string @@ -129,6 +130,7 @@ func main() { invokeTimeout: invokeTimeout, authUser: authUser, authPass: authPass, + testMode: os.Getenv("FISSION_TEST_MODE") == "true", } mux := http.NewServeMux() @@ -154,24 +156,40 @@ func main() { auth := func(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - 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 - } - ns, err := namespaceFromJWT(token) - if err != nil { - log.Printf("namespaceFromJWT: %v", err) - ns = s.ns + var ns string + + if s.testMode { + // TEST_MODE: deck API не вызывается. + // X-Test-Sub задаёт произвольный sub → разные namespace-ы для тестирования. + sub := strings.TrimSpace(r.Header.Get("X-Test-Sub")) + 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) + if err != nil { + log.Printf("namespaceFromJWT: %v", err) + ns = s.ns + } } + ctx := context.WithValue(r.Context(), ctxKeyNS{}, ns) h(w, r.WithContext(ctx)) } @@ -258,6 +276,16 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { return } + // Гарантируем что namespace + RBAC созданы до любых операций с ресурсами. + // handleAuth делает это при логине, но в test mode или при прямом вызове API + // namespace может отсутствовать — создаём idempotent. + nsCtx, nsCancel := context.WithTimeout(r.Context(), 30*time.Second) + defer nsCancel() + if err := s.ensureUserNamespace(nsCtx, ns); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err)) + return + } + req.Name = strings.TrimSpace(req.Name) req.Language = strings.TrimSpace(req.Language) req.Environment = strings.TrimSpace(req.Environment) @@ -427,11 +455,11 @@ func (s *server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { } writeAnyJSON(w, http.StatusCreated, map[string]any{ - "name": req.Name, - "package": pkgName, + "name": req.Name, + "package": pkgName, "httptrigger": triggerName, - "route": req.Route, - "expires_at": fnAnnotations["fission-console/expires-at"], + "route": req.Route, + "expires_at": fnAnnotations["fission-console/expires-at"], }) } @@ -986,7 +1014,7 @@ func (s *server) addNSToFission(ctx context.Context, ns string) error { return fmt.Errorf("get router deployment: %w", err) } - currentVal := "default" // fallback если переменная не найдена + currentVal := "default" // fallback если переменная не найдена containerName := "router" // имя контейнера нужно для StrategicMergePatch containers, _, _ := unstructured.NestedSlice(routerDep.Object, "spec", "template", "spec", "containers") for _, c := range containers { @@ -1083,60 +1111,60 @@ func (s *server) ensureUserNamespace(ctx context.Context, ns string) error { return fmt.Errorf("create namespace %s: %w", ns, err) } -// 1b. RoleBindings для Fission SA в user namespace. - // - // Проблема: Fission компоненты (executor, router, buildermgr и др.) работают в namespace - // "fission", но при добавлении нового namespace в FISSION_RESOURCE_NAMESPACES они начинают - // туда смотреть (list/watch). По умолчанию у их SA нет прав в чужих namespace-ах → - // "forbidden: cannot list environments.fission.io in namespace X". - // - // Почему cluster-admin, а не admin: - // ClusterRole "admin" не включает custom resource группы (fission.io/*). - // Fission executor при старте пытается создать Role с правами на fission.io/packages, - // и получает "attempting to grant RBAC permissions not currently held" — RBAC escalation - // prevention. ClusterRole "cluster-admin" в контексте RoleBinding (не ClusterRoleBinding) - // даёт полный доступ ТОЛЬКО внутри конкретного namespace — это безопасно. - // - // Операция idempotent: если RoleBinding уже существует — IsAlreadyExists игнорируется. - fissionSAs := []string{"fission-executor", "fission-router", "fission-buildermgr", "fission-kubewatcher", "fission-timer", "fission-fetcher", "fission-builder"} - fissionSysNS := os.Getenv("FISSION_SYSTEM_NAMESPACE") - if fissionSysNS == "" { - fissionSysNS = "fission" - } - rbGVR := schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "rolebindings"} - for _, sa := range fissionSAs { - rbObj := &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "rbac.authorization.k8s.io/v1", - "kind": "RoleBinding", - "metadata": map[string]any{ - "name": "fission-" + sa + "-user-ns", - "namespace": ns, - }, - "roleRef": map[string]any{ - "apiGroup": "rbac.authorization.k8s.io", - "kind": "ClusterRole", - "name": "cluster-admin", // namespace-scoped через RoleBinding, не ClusterRoleBinding - }, - "subjects": []any{ - map[string]any{ - "kind": "ServiceAccount", - "name": sa, - "namespace": fissionSysNS, - }, - }, - }, - } - _, rbErr := s.dyn.Resource(rbGVR).Namespace(ns).Create(ctx, rbObj, metav1.CreateOptions{}) - if rbErr != nil && !apierrors.IsAlreadyExists(rbErr) { - log.Printf("ensureUserNamespace: create rolebinding %s/%s: %v", ns, sa, rbErr) - } - } + // 1b. RoleBindings для Fission SA в user namespace. + // + // Проблема: Fission компоненты (executor, router, buildermgr и др.) работают в namespace + // "fission", но при добавлении нового namespace в FISSION_RESOURCE_NAMESPACES они начинают + // туда смотреть (list/watch). По умолчанию у их SA нет прав в чужих namespace-ах → + // "forbidden: cannot list environments.fission.io in namespace X". + // + // Почему cluster-admin, а не admin: + // ClusterRole "admin" не включает custom resource группы (fission.io/*). + // Fission executor при старте пытается создать Role с правами на fission.io/packages, + // и получает "attempting to grant RBAC permissions not currently held" — RBAC escalation + // prevention. ClusterRole "cluster-admin" в контексте RoleBinding (не ClusterRoleBinding) + // даёт полный доступ ТОЛЬКО внутри конкретного namespace — это безопасно. + // + // Операция idempotent: если RoleBinding уже существует — IsAlreadyExists игнорируется. + fissionSAs := []string{"fission-executor", "fission-router", "fission-buildermgr", "fission-kubewatcher", "fission-timer", "fission-fetcher", "fission-builder"} + fissionSysNS := os.Getenv("FISSION_SYSTEM_NAMESPACE") + if fissionSysNS == "" { + fissionSysNS = "fission" + } + rbGVR := schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "rolebindings"} + for _, sa := range fissionSAs { + rbObj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": map[string]any{ + "name": "fission-" + sa + "-user-ns", + "namespace": ns, + }, + "roleRef": map[string]any{ + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "cluster-admin", // namespace-scoped через RoleBinding, не ClusterRoleBinding + }, + "subjects": []any{ + map[string]any{ + "kind": "ServiceAccount", + "name": sa, + "namespace": fissionSysNS, + }, + }, + }, + } + _, rbErr := s.dyn.Resource(rbGVR).Namespace(ns).Create(ctx, rbObj, metav1.CreateOptions{}) + if rbErr != nil && !apierrors.IsAlreadyExists(rbErr) { + log.Printf("ensureUserNamespace: create rolebinding %s/%s: %v", ns, sa, rbErr) + } + } - // 1c. Регистрируем новый namespace в Fission (FISSION_RESOURCE_NAMESPACES). - // Только при первом создании — повторный патч не нужен, Fission уже знает о namespace. - // addNSToFission читает текущее значение переменной у router-а, добавляет ns и патчит - // все Fission deployments (router, executor, buildermgr, kubewatcher, timer). + // 1c. Регистрируем новый namespace в Fission (FISSION_RESOURCE_NAMESPACES). + // Только при первом создании — повторный патч не нужен, Fission уже знает о namespace. + // addNSToFission читает текущее значение переменной у router-а, добавляет ns и патчит + // все Fission deployments (router, executor, buildermgr, kubewatcher, timer). if newlyCreated { if patchErr := s.addNSToFission(ctx, ns); patchErr != nil { log.Printf("ensureUserNamespace: addNSToFission: %v", patchErr) diff --git a/deploy/rbac/console-ns-manager.yaml b/deploy/rbac/console-ns-manager.yaml index 4acd5b8..db1ab9f 100644 --- a/deploy/rbac/console-ns-manager.yaml +++ b/deploy/rbac/console-ns-manager.yaml @@ -5,13 +5,19 @@ metadata: rules: - apiGroups: [""] resources: ["namespaces"] - verbs: ["get", "create"] + verbs: ["get", "create", "list"] - apiGroups: ["fission.io"] resources: ["*"] verbs: ["*"] - apiGroups: ["rbac.authorization.k8s.io"] resources: ["rolebindings"] verbs: ["get", "create"] +# bind позволяет создавать RoleBindings ссылающиеся на cluster-admin +# без необходимости самому иметь все его права (RBAC escalation prevention) +- apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles"] + resourceNames: ["cluster-admin"] + verbs: ["bind"] - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "patch"]