Skip to content

Commit 8b5fbcf

Browse files
authored
Merge branch 'main' into feature/valkey-memory-backend
2 parents 5b51f33 + aefc60c commit 8b5fbcf

31 files changed

+3712
-75
lines changed

.github/workflows/ci-changes.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,11 @@ jobs:
182182
e2e_dashboard:
183183
- 'e2e/profiles/dashboard/**'
184184
- 'e2e/testcases/dashboard_*.go'
185+
- 'e2e/testcases/security_policy_apply.go'
185186
- 'deploy/kubernetes/observability/dashboard/**'
187+
- 'dashboard/backend/handlers/security_policy*.go'
188+
- 'dashboard/backend/handlers/deploy.go'
189+
- 'dashboard/backend/handlers/canonical_transport.go'
186190
e2e_dynamic_config:
187191
- 'e2e/profiles/dynamic-config/**'
188192
e2e_llm_d:
@@ -221,6 +225,7 @@ jobs:
221225
- 'e2e/config/authz-profile-*.yaml'
222226
- 'src/semantic-router/pkg/classification/authz*.go'
223227
- 'src/semantic-router/pkg/authz/**'
228+
- 'dashboard/backend/handlers/security_policy*.go'
224229
e2e_streaming:
225230
- 'e2e/profiles/streaming/**'
226231
- 'deploy/kubernetes/streaming/**'

dashboard/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Pages:
4141
- **Topology** (`/topology`): Visual topology of request flow and model selection using React Flow
4242
- **Playground** (`/playground`): Built-in chat playground for testing
4343
- **ML Setup** (`/ml-setup`): 3-step wizard for ML model selection — benchmark, train, and generate deployment config
44+
- **Security Policy** (`/security`): RBAC-to-router integration — map roles/groups to models and rate-limit tiers, preview generated router config fragments
4445

4546
Features:
4647

@@ -100,6 +101,9 @@ Read-only dashboard mode:
100101
- `GET /api/ml-pipeline/jobs/{id}` → Get job status and output files
101102
- `GET /api/ml-pipeline/stream/{id}` → SSE stream for real-time job progress
102103
- `GET /api/ml-pipeline/download/{id}/{filename}` → Download job output files
104+
- `GET /api/security/policy` → Get current security policy config
105+
- `PUT /api/security/policy` → Update security policy, generate router config fragment, and auto-apply to router config
106+
- `POST /api/security/policy/preview` → Preview generated fragment without saving
103107
- Normalizes headers for iframe embedding: strips/overrides `X-Frame-Options` and `Content-Security-Policy` frame-ancestors as needed
104108
- SPA routing support: serves `index.html` for all non-asset routes
105109
- Central point for JWT/OIDC in the future (forward or exchange tokens to upstreams)
@@ -124,6 +128,7 @@ dashboard/
124128
│ │ │ ├── ConfigPage.tsx # Config viewer with API fetch
125129
│ │ │ ├── PlaygroundPage.tsx # Built-in chat playground
126130
│ │ │ ├── MLSetupPage.tsx # ML model selection 3-step wizard
131+
│ │ │ ├── SecurityPolicyPage.tsx # RBAC role-to-model & rate-limit management
127132
│ │ │ └── *.module.css # Scoped styles per page
128133
│ │ ├── hooks/
129134
│ │ │ └── useMLPipeline.ts # ML pipeline state management & API hooks
@@ -138,6 +143,7 @@ dashboard/
138143
├── backend/ # Go reverse proxy server
139144
│ ├── main.go # Proxy routes & static file server
140145
│ ├── handlers/mlpipeline.go # ML pipeline HTTP handlers & SSE streaming
146+
│ ├── handlers/security_policy.go # Security policy API & config fragment generation
141147
│ ├── mlpipeline/runner.go # ML job orchestration (benchmark, train, config gen)
142148
│ ├── go.mod # Go module (minimal dependencies)
143149
│ └── Dockerfile # Multi-stage build (Node + Go + Alpine)
@@ -215,7 +221,9 @@ Recommended upstream settings for embedding:
215221
- Dashboard auth uses JWTs from `Authorization: Bearer <token>` for protected `/api/*` and `/embedded/*` requests.
216222
- Protected embedded entry URLs may also carry `authToken=<token>`, and the frontend mirrors the active token into a same-origin `vsr_session` cookie so Grafana/Jaeger iframe redirects and in-frame `/api/*` calls stay authenticated.
217223
- Frame embedding: backend strips/overrides `X-Frame-Options` and `Content-Security-Policy` headers from upstreams to permit `frame-ancestors 'self'` only.
218-
- Future: OIDC login on dashboard, stronger session-cookie handling, per-route RBAC, and signed proxy sessions to embedded services.
224+
- **Security Policy page** (`/security`, accessible via Manager dropdown): allows admins to define role-to-model RBAC mappings and per-role rate-limit tiers. On save, the dashboard translates these into canonical router config (`routing.signals.role_bindings`, `routing.decisions`, and `global.services.ratelimit`), merges them into the running `config.yaml`, and triggers a hot-reload so the router enforces the new policy immediately. Requires the `security.manage` permission for writes; `config.read` is sufficient for viewing. See [docs/security-hardening.md](../docs/security-hardening.md) for full details.
225+
- **Dashboard RBAC permissions**: `feedback.submit`, `replay.read`, and `security.manage` extend the built-in role/permission matrix. Only admin-role users receive `security.manage` by default.
226+
- Future: OIDC login on dashboard, stronger session-cookie handling, and signed proxy sessions to embedded services.
219227

220228
Write access warning for config updates:
221229

dashboard/backend/auth/middleware.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,20 @@ func featurePermission(method, path string) (string, bool) {
188188
return openclawPermission(method, path)
189189
case strings.HasPrefix(path, "/api/ml-pipeline/"):
190190
return PermMlPipeline, true
191+
case strings.HasPrefix(path, "/api/security/"):
192+
return securityPermission(method), true
191193
default:
192194
return "", false
193195
}
194196
}
195197

198+
func securityPermission(method string) string {
199+
if method == http.MethodGet {
200+
return PermConfigRead
201+
}
202+
return PermSecurityManage
203+
}
204+
196205
func openclawPermission(method, path string) (string, bool) {
197206
switch {
198207
case strings.HasPrefix(path, "/embedded/openclaw/"):

dashboard/backend/auth/middleware_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ func TestRequiredPermission(t *testing.T) {
9090
{method: http.MethodPost, path: "/api/openclaw/teams", expected: PermOpenClaw},
9191
{method: http.MethodPost, path: "/api/openclaw/rooms/room-1/messages", expected: PermOpenClawRead},
9292
{method: http.MethodPost, path: "/api/router/v1/chat/completions", expected: PermConfigRead},
93+
{method: http.MethodGet, path: "/api/security/policy", expected: PermConfigRead},
94+
{method: http.MethodPut, path: "/api/security/policy", expected: PermSecurityManage},
95+
{method: http.MethodPost, path: "/api/security/policy/preview", expected: PermSecurityManage},
9396
}
9497

9598
for _, tc := range testCases {

dashboard/backend/auth/schema.go

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,31 @@ const (
2020
)
2121

2222
const (
23-
PermUsersManage = "users.manage"
24-
PermUsersView = "users.view"
25-
PermConfigRead = "config.read"
26-
PermConfigWrite = "config.write"
27-
PermConfigDeploy = "config.deploy"
28-
PermEvalRead = "evaluation.read"
29-
PermEvalWrite = "evaluation.write"
30-
PermEvalRun = "evaluation.run"
31-
PermTopologyRead = "topology.read"
32-
PermLogsRead = "logs.read"
33-
PermOpenClawRead = "openclaw.read"
34-
PermOpenClaw = "openclaw.manage"
35-
PermMcpRead = "mcp.read"
36-
PermMcpManage = "mcp.manage"
37-
PermToolsUse = "tools.use"
38-
PermMlPipeline = "mlpipeline.manage"
23+
PermUsersManage = "users.manage"
24+
PermUsersView = "users.view"
25+
PermConfigRead = "config.read"
26+
PermConfigWrite = "config.write"
27+
PermConfigDeploy = "config.deploy"
28+
PermEvalRead = "evaluation.read"
29+
PermEvalWrite = "evaluation.write"
30+
PermEvalRun = "evaluation.run"
31+
PermTopologyRead = "topology.read"
32+
PermLogsRead = "logs.read"
33+
PermOpenClawRead = "openclaw.read"
34+
PermOpenClaw = "openclaw.manage"
35+
PermMcpRead = "mcp.read"
36+
PermMcpManage = "mcp.manage"
37+
PermToolsUse = "tools.use"
38+
PermMlPipeline = "mlpipeline.manage"
39+
PermFeedbackSubmit = "feedback.submit"
40+
PermReplayRead = "replay.read"
41+
PermSecurityManage = "security.manage"
3942
)
4043

4144
var DefaultRolePermissions = map[string][]string{
42-
RoleAdmin: {PermUsersManage, PermUsersView, PermConfigRead, PermConfigWrite, PermConfigDeploy, PermEvalRead, PermEvalWrite, PermEvalRun, PermTopologyRead, PermLogsRead, PermOpenClawRead, PermOpenClaw, PermMcpRead, PermMcpManage, PermToolsUse, PermMlPipeline},
43-
RoleWrite: {PermConfigRead, PermConfigWrite, PermConfigDeploy, PermEvalRead, PermEvalWrite, PermEvalRun, PermTopologyRead, PermLogsRead, PermOpenClawRead, PermOpenClaw, PermMcpRead, PermMcpManage, PermToolsUse, PermMlPipeline},
44-
RoleRead: {PermConfigRead, PermEvalRead, PermTopologyRead, PermLogsRead, PermOpenClawRead, PermMcpRead, PermToolsUse},
45+
RoleAdmin: {PermUsersManage, PermUsersView, PermConfigRead, PermConfigWrite, PermConfigDeploy, PermEvalRead, PermEvalWrite, PermEvalRun, PermTopologyRead, PermLogsRead, PermOpenClawRead, PermOpenClaw, PermMcpRead, PermMcpManage, PermToolsUse, PermMlPipeline, PermFeedbackSubmit, PermReplayRead, PermSecurityManage},
46+
RoleWrite: {PermConfigRead, PermConfigWrite, PermConfigDeploy, PermEvalRead, PermEvalWrite, PermEvalRun, PermTopologyRead, PermLogsRead, PermOpenClawRead, PermOpenClaw, PermMcpRead, PermMcpManage, PermToolsUse, PermMlPipeline, PermFeedbackSubmit, PermReplayRead},
47+
RoleRead: {PermConfigRead, PermEvalRead, PermTopologyRead, PermLogsRead, PermOpenClawRead, PermMcpRead, PermToolsUse, PermFeedbackSubmit, PermReplayRead},
4548
}
4649

4750
var SupportedRoles = []string{RoleAdmin, RoleWrite, RoleRead}
@@ -57,6 +60,7 @@ var AllPermissions = []string{
5760
PermUsersManage, PermUsersView, PermConfigRead, PermConfigWrite, PermConfigDeploy,
5861
PermEvalRead, PermEvalWrite, PermEvalRun, PermTopologyRead, PermLogsRead, PermOpenClawRead,
5962
PermOpenClaw, PermMcpRead, PermMcpManage, PermToolsUse, PermMlPipeline,
63+
PermFeedbackSubmit, PermReplayRead, PermSecurityManage,
6064
}
6165

6266
func normalizeRole(raw string) (string, error) {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package auth
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestNewPermissionsExistInAllPermissions(t *testing.T) {
8+
t.Parallel()
9+
10+
requiredPerms := []string{PermFeedbackSubmit, PermReplayRead, PermSecurityManage}
11+
allSet := make(map[string]bool, len(AllPermissions))
12+
for _, p := range AllPermissions {
13+
allSet[p] = true
14+
}
15+
16+
for _, perm := range requiredPerms {
17+
if !allSet[perm] {
18+
t.Fatalf("permission %q missing from AllPermissions", perm)
19+
}
20+
}
21+
}
22+
23+
func TestAdminRoleHasSecurityManage(t *testing.T) {
24+
t.Parallel()
25+
26+
adminPerms := DefaultRolePermissions[RoleAdmin]
27+
found := false
28+
for _, p := range adminPerms {
29+
if p == PermSecurityManage {
30+
found = true
31+
break
32+
}
33+
}
34+
if !found {
35+
t.Fatalf("admin role should have %q permission", PermSecurityManage)
36+
}
37+
}
38+
39+
func TestWriteRoleDoesNotHaveSecurityManage(t *testing.T) {
40+
t.Parallel()
41+
42+
writePerms := DefaultRolePermissions[RoleWrite]
43+
for _, p := range writePerms {
44+
if p == PermSecurityManage {
45+
t.Fatalf("write role should not have %q permission", PermSecurityManage)
46+
}
47+
}
48+
}
49+
50+
func TestReadRoleDoesNotHaveSecurityManage(t *testing.T) {
51+
t.Parallel()
52+
53+
readPerms := DefaultRolePermissions[RoleRead]
54+
for _, p := range readPerms {
55+
if p == PermSecurityManage {
56+
t.Fatalf("read role should not have %q permission", PermSecurityManage)
57+
}
58+
}
59+
}
60+
61+
func TestAllRolesHaveFeedbackSubmitAndReplayRead(t *testing.T) {
62+
t.Parallel()
63+
64+
for _, role := range SupportedRoles {
65+
perms := DefaultRolePermissions[role]
66+
hasFeedback := false
67+
hasReplay := false
68+
for _, p := range perms {
69+
if p == PermFeedbackSubmit {
70+
hasFeedback = true
71+
}
72+
if p == PermReplayRead {
73+
hasReplay = true
74+
}
75+
}
76+
if !hasFeedback {
77+
t.Fatalf("role %q should have %q permission", role, PermFeedbackSubmit)
78+
}
79+
if !hasReplay {
80+
t.Fatalf("role %q should have %q permission", role, PermReplayRead)
81+
}
82+
}
83+
}
84+
85+
func TestDefaultRolePermissionsCoversAllSupportedRoles(t *testing.T) {
86+
t.Parallel()
87+
88+
for _, role := range SupportedRoles {
89+
if _, ok := DefaultRolePermissions[role]; !ok {
90+
t.Fatalf("role %q missing from DefaultRolePermissions", role)
91+
}
92+
}
93+
}

dashboard/backend/handlers/canonical_transport.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,17 @@ import (
1414
routerconfig "github.com/vllm-project/semantic-router/src/semantic-router/pkg/config"
1515
)
1616

17+
type globalServicesFragment struct {
18+
RateLimit *routerconfig.RateLimitConfig `yaml:"ratelimit,omitempty"`
19+
}
20+
21+
type globalFragment struct {
22+
Services *globalServicesFragment `yaml:"services,omitempty"`
23+
}
24+
1725
type routingFragmentDocument struct {
1826
Routing routerconfig.CanonicalRouting `yaml:"routing"`
27+
Global *globalFragment `yaml:"global,omitempty"`
1928
}
2029

2130
type setupModeConfig struct {

dashboard/backend/handlers/deploy.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ func mergeDeployPayload(currentData []byte, req DeployRequest) ([]byte, error) {
302302
}
303303

304304
baseConfig.Routing = mergeCanonicalRouting(baseConfig.Routing, fragmentConfig.Routing)
305+
mergeFragmentGlobal(&baseConfig, fragmentConfig.Global)
305306
mergedYAML, err := marshalYAMLBytes(baseConfig)
306307
if err != nil {
307308
return nil, fmt.Errorf("failed to marshal merged config: %w", err)
@@ -365,6 +366,16 @@ func mergeCanonicalSignals(base, patch routerconfig.CanonicalSignals) routerconf
365366
return merged
366367
}
367368

369+
func mergeFragmentGlobal(base *routerconfig.CanonicalConfig, frag *globalFragment) {
370+
if frag == nil || frag.Services == nil || frag.Services.RateLimit == nil {
371+
return
372+
}
373+
if base.Global == nil {
374+
base.Global = &routerconfig.CanonicalGlobal{}
375+
}
376+
base.Global.Services.RateLimit = *frag.Services.RateLimit
377+
}
378+
368379
func resolveDeployBaseYAML(currentData []byte, providedBase string) ([]byte, error) {
369380
if strings.TrimSpace(providedBase) == "" {
370381
return currentData, nil

0 commit comments

Comments
 (0)