mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
* fix(gateway): admit operator.provision keys on tenants.create and tenants.users.add The CVE #866 fail-closed hardening regressed operator.provision: the role-only router check maps provision-only API keys to viewer and rejects tenants.create / tenants.users.add with 'requires admin role', even though the tenant handlers explicitly admit ScopeProvision on exactly those two methods (issue #1524). Restore the intended least-privilege provisioning path without any role promotion: the router now allows credentials carrying ScopeProvision on exactly the two tenant-provisioning RPCs (permissions.IsProvisionMethod). Every other admin/write surface stays denied, and viewers without the provision scope gain nothing. Regression tests (internal/gateway/router_test.go) prove: - provision-only succeeds on tenants.create and tenants.users.add - provision-only stays denied on tenants.update and agents.create - plain viewers stay denied on the provisioning methods - unauthenticated clients stay denied * refactor(gateway): route provisionScopeAllowed through permissions.HasProvisionScope Address review feedback on #1584: HasProvisionScope was exported and tested but had no production caller — the router used Client.HasScope directly. Wire the router to the permissions helper so the provision scope check has a single source of truth.
154 lines
5.5 KiB
Go
154 lines
5.5 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
func TestHandleConnectRejectsNoTokenExternalBind(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.Gateway.Host = "0.0.0.0"
|
|
cfg.Gateway.Token = ""
|
|
t.Setenv(config.GatewayAllowInsecureNoAuthEnv, "")
|
|
|
|
server := NewServer(cfg, nil, nil, nil)
|
|
client := NewClient(nil, server, "203.0.113.10")
|
|
req := &protocol.RequestFrame{ID: "req-1", Method: protocol.MethodConnect}
|
|
|
|
server.router.Handle(context.Background(), client, req)
|
|
|
|
if client.authenticated {
|
|
t.Fatal("expected unauthenticated client for external no-token connect")
|
|
}
|
|
if client.role != "" {
|
|
t.Fatalf("role = %q, want empty", client.role)
|
|
}
|
|
select {
|
|
case raw := <-client.send:
|
|
var resp protocol.ResponseFrame
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrUnauthorized {
|
|
t.Fatalf("response error = %#v, want unauthorized", resp.Error)
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("expected unauthorized response")
|
|
}
|
|
}
|
|
|
|
func TestHandleConnectAllowsExplicitInsecureNoTokenOptIn(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.Gateway.Host = "0.0.0.0"
|
|
cfg.Gateway.Token = ""
|
|
t.Setenv(config.GatewayAllowInsecureNoAuthEnv, "1")
|
|
|
|
server := NewServer(cfg, nil, nil, nil)
|
|
client := NewClient(nil, server, "127.0.0.1")
|
|
req := &protocol.RequestFrame{ID: "req-1", Method: protocol.MethodConnect}
|
|
|
|
server.router.Handle(context.Background(), client, req)
|
|
|
|
if !client.authenticated {
|
|
t.Fatal("expected authenticated client with explicit insecure opt-in")
|
|
}
|
|
if client.role != permissions.RoleOperator {
|
|
t.Fatalf("role = %q, want operator", client.role)
|
|
}
|
|
}
|
|
|
|
// callMethod dispatches method through the router and returns the response frame.
|
|
func callMethod(t *testing.T, server *Server, client *Client, method string) *protocol.ResponseFrame {
|
|
t.Helper()
|
|
req := &protocol.RequestFrame{ID: "req-" + method, Method: method}
|
|
server.router.Handle(context.Background(), client, req)
|
|
select {
|
|
case raw := <-client.send:
|
|
var resp protocol.ResponseFrame
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
return &resp
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatalf("expected a response frame for %s", method)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// TestRouterProvisionScopeTenantMethods covers the narrow method-scoped
|
|
// exception for operator.provision credentials (issue #1524): provision-only
|
|
// API keys are admitted on exactly tenants.create and tenants.users.add —
|
|
// the two methods the tenant handlers already gate on ScopeProvision — while
|
|
// every other admin/write surface stays denied and plain viewers gain nothing.
|
|
func TestRouterProvisionScopeTenantMethods(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.Gateway.Host = "127.0.0.1"
|
|
cfg.Gateway.Token = "test-token"
|
|
|
|
server := NewServer(cfg, nil, nil, nil)
|
|
server.SetPolicyEngine(permissions.NewPolicyEngine(nil))
|
|
|
|
reached := map[string]bool{}
|
|
for _, method := range []string{
|
|
protocol.MethodTenantsCreate,
|
|
protocol.MethodTenantsUsersAdd,
|
|
protocol.MethodTenantsUpdate,
|
|
protocol.MethodAgentsCreate,
|
|
} {
|
|
m := method
|
|
server.router.Register(m, func(ctx context.Context, c *Client, req *protocol.RequestFrame) {
|
|
reached[m] = true
|
|
c.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
|
})
|
|
}
|
|
|
|
// Provision-only API key: RoleFromScopes maps it to viewer; scopes carry
|
|
// operator.provision (as the WS connect path would set them).
|
|
prov, _ := NewCapturingTestClient(permissions.RoleViewer, uuid.Nil, "provisioner", 8)
|
|
prov.scopes = []permissions.Scope{permissions.ScopeProvision}
|
|
|
|
// 1) Provision-only succeeds on the two tenant-provisioning methods.
|
|
for _, method := range []string{protocol.MethodTenantsCreate, protocol.MethodTenantsUsersAdd} {
|
|
resp := callMethod(t, server, prov, method)
|
|
if resp.Error != nil {
|
|
t.Fatalf("provision-only %s: unexpected error %v (code %s)", method, resp.Error.Message, resp.Error.Code)
|
|
}
|
|
if !reached[method] {
|
|
t.Fatalf("provision-only %s: handler never invoked", method)
|
|
}
|
|
}
|
|
|
|
// 2) Provision-only remains denied on other admin/write surfaces.
|
|
for _, method := range []string{protocol.MethodTenantsUpdate, protocol.MethodAgentsCreate} {
|
|
resp := callMethod(t, server, prov, method)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrUnauthorized {
|
|
t.Fatalf("provision-only %s: want unauthorized, got error=%v reached=%v", method, resp.Error, reached[method])
|
|
}
|
|
}
|
|
|
|
// 3) A viewer WITHOUT the provision scope still cannot reach the two
|
|
// tenant-provisioning methods.
|
|
viewer, _ := NewCapturingTestClient(permissions.RoleViewer, uuid.Nil, "viewer", 8)
|
|
for _, method := range []string{protocol.MethodTenantsCreate, protocol.MethodTenantsUsersAdd} {
|
|
resp := callMethod(t, server, viewer, method)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrUnauthorized {
|
|
t.Fatalf("plain viewer %s: want unauthorized, got error=%v reached=%v", method, resp.Error, reached[method])
|
|
}
|
|
}
|
|
|
|
// 4) An unauthenticated client stays denied.
|
|
anon, _ := NewCapturingTestClient("", uuid.Nil, "", 8)
|
|
resp := callMethod(t, server, anon, protocol.MethodTenantsCreate)
|
|
if resp.Error == nil || resp.Error.Code != protocol.ErrUnauthorized {
|
|
t.Fatalf("unauthenticated tenants.create: want unauthorized, got error=%v reached=%v", resp.Error, reached[protocol.MethodTenantsCreate])
|
|
}
|
|
}
|