Skip to content

Commit 7bbf1ce

Browse files
authored
feat: improve system status graph and add developer role (#1664)
1 parent d496dde commit 7bbf1ce

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1020
-408
lines changed

api/v1/api.gen.go

Lines changed: 270 additions & 255 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1/api.yaml

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,7 @@ paths:
659659
/workers:
660660
get:
661661
summary: "List distributed workers"
662-
description: "Retrieves information about distributed workers connected to the coordinator"
662+
description: "Retrieves information about distributed workers connected to the coordinator. Developer, manager, or admin only."
663663
operationId: "getWorkers"
664664
tags:
665665
- "system"
@@ -2807,7 +2807,7 @@ paths:
28072807
/services/resources/history:
28082808
get:
28092809
summary: "Get resource usage history"
2810-
description: "Returns historical data for system resources"
2810+
description: "Returns historical data for system resources. Developer, manager, or admin only."
28112811
operationId: "getResourceHistory"
28122812
tags:
28132813
- "system"
@@ -2837,7 +2837,7 @@ paths:
28372837
/services/scheduler:
28382838
get:
28392839
summary: "Get scheduler service status"
2840-
description: "Returns status information about all registered scheduler instances"
2840+
description: "Returns status information about all registered scheduler instances. Developer, manager, or admin only."
28412841
operationId: "getSchedulerStatus"
28422842
tags:
28432843
- "system"
@@ -2860,7 +2860,7 @@ paths:
28602860
/services/coordinator:
28612861
get:
28622862
summary: "Get coordinator service status"
2863-
description: "Returns status information about all registered coordinator instances"
2863+
description: "Returns status information about all registered coordinator instances. Developer, manager, or admin only."
28642864
operationId: "getCoordinatorStatus"
28652865
tags:
28662866
- "system"
@@ -2883,7 +2883,7 @@ paths:
28832883
/services/tunnel:
28842884
get:
28852885
summary: "Get tunnel service status"
2886-
description: "Returns status information about the tunnel service (Tailscale)"
2886+
description: "Returns status information about the tunnel service (Tailscale). Developer, manager, or admin only."
28872887
operationId: "getTunnelStatus"
28882888
tags:
28892889
- "system"
@@ -3031,7 +3031,7 @@ paths:
30313031
/webhooks:
30323032
get:
30333033
summary: "List all webhooks"
3034-
description: "Returns a list of all webhooks across all DAGs. Admin only."
3034+
description: "Returns a list of all webhooks across all DAGs. Developer, manager, or admin only."
30353035
operationId: "listWebhooks"
30363036
tags:
30373037
- "webhooks"
@@ -3086,6 +3086,7 @@ paths:
30863086
description: |
30873087
Creates a new webhook for the specified DAG. Returns the full webhook token,
30883088
which is only shown once. Store it securely.
3089+
Developer, manager, or admin only.
30893090
operationId: "createDAGWebhook"
30903091
tags:
30913092
- "webhooks"
@@ -3114,7 +3115,7 @@ paths:
31143115

31153116
delete:
31163117
summary: "Delete webhook for DAG"
3117-
description: "Removes the webhook configuration for the specified DAG."
3118+
description: "Removes the webhook configuration for the specified DAG. Developer, manager, or admin only."
31183119
operationId: "deleteDAGWebhook"
31193120
tags:
31203121
- "webhooks"
@@ -3143,6 +3144,7 @@ paths:
31433144
description: |
31443145
Generates a new token for the existing webhook. The old token becomes
31453146
invalid immediately. Returns the new token, which is only shown once.
3147+
Developer, manager, or admin only.
31463148
operationId: "regenerateDAGWebhookToken"
31473149
tags:
31483150
- "webhooks"
@@ -3172,7 +3174,7 @@ paths:
31723174
/dags/{fileName}/webhook/toggle:
31733175
post:
31743176
summary: "Toggle webhook enabled state"
3175-
description: "Enables or disables the webhook without changing the token."
3177+
description: "Enables or disables the webhook without changing the token. Developer, manager, or admin only."
31763178
operationId: "toggleDAGWebhook"
31773179
tags:
31783180
- "webhooks"
@@ -3208,7 +3210,7 @@ paths:
32083210
/audit:
32093211
get:
32103212
summary: "List audit log entries"
3211-
description: "Returns audit log entries matching the filter criteria. Admin only."
3213+
description: "Returns audit log entries matching the filter criteria. Manager or admin only."
32123214
operationId: "listAuditLogs"
32133215
tags:
32143216
- "audit"
@@ -3271,7 +3273,7 @@ paths:
32713273
schema:
32723274
$ref: "#/components/schemas/Error"
32733275
"403":
3274-
description: "Forbidden - requires admin role"
3276+
description: "Forbidden - requires manager or admin role"
32753277
content:
32763278
application/json:
32773279
schema:
@@ -5893,6 +5895,22 @@ components:
58935895
type: array
58945896
items:
58955897
$ref: "#/components/schemas/MetricPoint"
5898+
memoryTotalBytes:
5899+
type: integer
5900+
format: int64
5901+
description: Total physical memory in bytes
5902+
memoryUsedBytes:
5903+
type: integer
5904+
format: int64
5905+
description: Used physical memory in bytes
5906+
diskTotalBytes:
5907+
type: integer
5908+
format: int64
5909+
description: Total disk space in bytes
5910+
diskUsedBytes:
5911+
type: integer
5912+
format: int64
5913+
description: Used disk space in bytes
58965914

58975915
MetricPoint:
58985916
type: object
@@ -5910,10 +5928,11 @@ components:
59105928

59115929
UserRole:
59125930
type: string
5913-
description: "User role determining access permissions. admin: full access including user management, manager: DAG CRUD and execution, operator: DAG execution only, viewer: read-only"
5931+
description: "User role determining access permissions. admin: full access including user management, manager: DAG CRUD and execution with audit log access, developer: DAG CRUD and execution, operator: DAG execution only, viewer: read-only"
59145932
enum:
59155933
- admin
59165934
- manager
5935+
- developer
59175936
- operator
59185937
- viewer
59195938

internal/agent/api.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const maxRequestBodySize = 1 << 20
3838
// defaultUserID is used when no user is authenticated (e.g., auth disabled).
3939
// This value should match the system's expected default user identifier.
4040
const defaultUserID = "admin"
41+
const defaultUserRole = auth.RoleAdmin
4142

4243
// getUserIDFromContext extracts the user ID from the request context.
4344
// Returns "admin" if no user is authenticated (e.g., auth mode is "none").
@@ -49,11 +50,13 @@ func getUserIDFromContext(ctx context.Context) string {
4950
}
5051

5152
// getUserContextFromRequest extracts user identity and IP from the request context.
52-
func getUserContextFromRequest(r *http.Request) (userID, username, ipAddress string) {
53+
func getUserContextFromRequest(r *http.Request) (userID, username string, role auth.Role, ipAddress string) {
5354
userID, username = defaultUserID, defaultUserID
55+
role = defaultUserRole
5456
if user, ok := auth.UserFromContext(r.Context()); ok && user != nil {
5557
userID = user.ID
5658
username = user.Username
59+
role = user.Role
5760
}
5861
ipAddress, _ = auth.ClientIPFromContext(r.Context())
5962
return
@@ -331,7 +334,7 @@ func (a *API) handleNewSession(w http.ResponseWriter, r *http.Request) {
331334
return
332335
}
333336

334-
userID, username, ipAddress := getUserContextFromRequest(r)
337+
userID, username, role, ipAddress := getUserContextFromRequest(r)
335338
model := selectModel(req.Model, "", a.getDefaultModelID(r.Context()))
336339

337340
provider, modelCfg, err := a.resolveProvider(r.Context(), model)
@@ -362,6 +365,7 @@ func (a *API) handleNewSession(w http.ResponseWriter, r *http.Request) {
362365
Hooks: a.hooks,
363366
Username: username,
364367
IPAddress: ipAddress,
368+
Role: role,
365369
InputCostPer1M: modelCfg.InputCostPer1M,
366370
OutputCostPer1M: modelCfg.OutputCostPer1M,
367371
MemoryStore: a.memoryStore,
@@ -534,13 +538,14 @@ func (a *API) getStoredSession(ctx context.Context, id, userID string) (*Session
534538
// POST /api/v1/agent/sessions/{id}/chat
535539
func (a *API) handleChat(w http.ResponseWriter, r *http.Request) {
536540
id := chi.URLParam(r, "id")
537-
userID, username, ipAddress := getUserContextFromRequest(r)
541+
userID, username, role, ipAddress := getUserContextFromRequest(r)
538542

539-
mgr, ok := a.getOrReactivateSession(r.Context(), id, userID, username, ipAddress)
543+
mgr, ok := a.getOrReactivateSession(r.Context(), id, userID, username, role, ipAddress)
540544
if !ok {
541545
a.respondError(w, http.StatusNotFound, api.ErrorCodeNotFound, "Session not found")
542546
return
543547
}
548+
mgr.UpdateUserContext(username, ipAddress, role)
544549

545550
var req ChatRequest
546551
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
@@ -577,18 +582,18 @@ func (a *API) handleChat(w http.ResponseWriter, r *http.Request) {
577582
}
578583

579584
// getOrReactivateSession retrieves an active session or reactivates it from storage.
580-
func (a *API) getOrReactivateSession(ctx context.Context, id, userID, username, ipAddress string) (*SessionManager, bool) {
585+
func (a *API) getOrReactivateSession(ctx context.Context, id, userID, username string, role auth.Role, ipAddress string) (*SessionManager, bool) {
581586
// Check active sessions first
582587
if mgr, ok := a.getActiveSession(id, userID); ok {
583588
return mgr, true
584589
}
585590

586591
// Try to reactivate from store
587-
return a.reactivateSession(ctx, id, userID, username, ipAddress)
592+
return a.reactivateSession(ctx, id, userID, username, role, ipAddress)
588593
}
589594

590595
// reactivateSession restores a session from storage and makes it active.
591-
func (a *API) reactivateSession(ctx context.Context, id, userID, username, ipAddress string) (*SessionManager, bool) {
596+
func (a *API) reactivateSession(ctx context.Context, id, userID, username string, role auth.Role, ipAddress string) (*SessionManager, bool) {
592597
if a.store == nil {
593598
return nil, false
594599
}
@@ -624,6 +629,7 @@ func (a *API) reactivateSession(ctx context.Context, id, userID, username, ipAdd
624629
IPAddress: ipAddress,
625630
MemoryStore: a.memoryStore,
626631
DAGName: sess.DAGName,
632+
Role: role,
627633
})
628634
a.sessions.Store(id, mgr)
629635

internal/agent/bash.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"time"
1313

14+
"github.com/dagu-org/dagu/internal/auth"
1415
"github.com/dagu-org/dagu/internal/llm"
1516
"github.com/google/uuid"
1617
)
@@ -141,6 +142,9 @@ func bashRun(toolCtx ToolContext, input json.RawMessage) ToolOut {
141142
if args.Command == "" {
142143
return toolError("Command is required")
143144
}
145+
if toolCtx.Role != auth.Role("") && !toolCtx.Role.CanExecute() {
146+
return toolError("Permission denied: bash requires execute permission")
147+
}
144148

145149
if toolCtx.SafeMode && commandRequiresApproval(args.Command) {
146150
approved, err := requestApproval(toolCtx, args.Command)

internal/agent/bash_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88
"time"
99

10+
"github.com/dagu-org/dagu/internal/auth"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213
)
@@ -120,6 +121,21 @@ func TestBashTool_Run(t *testing.T) {
120121
assert.False(t, result.IsError)
121122
assert.Contains(t, result.Content, "test")
122123
})
124+
125+
t.Run("rejects role without execute permission", func(t *testing.T) {
126+
t.Parallel()
127+
128+
tool := NewBashTool()
129+
input := json.RawMessage(`{"command": "echo test"}`)
130+
131+
result := tool.Run(ToolContext{
132+
Context: context.Background(),
133+
Role: auth.RoleViewer,
134+
}, input)
135+
136+
assert.True(t, result.IsError)
137+
assert.Contains(t, result.Content, "requires execute permission")
138+
})
123139
}
124140

125141
func TestBashTool_Timeout(t *testing.T) {

internal/agent/hooks.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package agent
33
import (
44
"context"
55
"encoding/json"
6+
7+
"github.com/dagu-org/dagu/internal/auth"
68
)
79

810
// ToolExecInfo provides context about a tool execution for hooks.
@@ -13,6 +15,7 @@ type ToolExecInfo struct {
1315
UserID string
1416
Username string
1517
IPAddress string
18+
Role auth.Role
1619
Audit *AuditInfo // from AgentTool.Audit; nil = not audited
1720
}
1821

internal/agent/hooks_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"sync"
88
"testing"
99

10+
"github.com/dagu-org/dagu/internal/auth"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213
)
@@ -33,6 +34,7 @@ func TestHooks_AfterToolExec(t *testing.T) {
3334
UserID: "user-1",
3435
Username: "alice",
3536
IPAddress: "10.0.0.1",
37+
Role: auth.RoleDeveloper,
3638
}
3739
result := ToolOut{Content: "file.txt", IsError: false}
3840

@@ -43,6 +45,7 @@ func TestHooks_AfterToolExec(t *testing.T) {
4345
assert.Equal(t, "user-1", captured.info.UserID)
4446
assert.Equal(t, "alice", captured.info.Username)
4547
assert.Equal(t, "10.0.0.1", captured.info.IPAddress)
48+
assert.Equal(t, auth.RoleDeveloper, captured.info.Role)
4649
assert.Equal(t, "file.txt", captured.result.Content)
4750
assert.False(t, captured.result.IsError)
4851
}

0 commit comments

Comments
 (0)