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