feat(all): implement builtin user management feature (#1463)

* **New Features**
* Built-in RBAC auth with JWT login, token handling, and user lifecycle
APIs (list/create/view/update/delete, reset/change password).
* **UI**
* Login flow, protected routes, Users management page,
change/reset-password modals, user menu and role-aware navigation.
* **Behavior**
* v1 routes disabled when auth enabled; runtime config exposes authMode
and usersDir; client persists auth token.
* **Documentation**
  * Added builtin auth docs and new env/config options.
* **Tests**
* Extensive tests for auth service, file-backed store, and API handlers.
This commit is contained in:
Yota Hamada 2025-12-09 18:09:11 +09:00 committed by GitHub
parent b4c857d7b4
commit d4b8484ca8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 7656 additions and 294 deletions

View File

@ -44,7 +44,7 @@ Built for developers who want powerful workflow orchestration without the operat
**Built-in queue system with intelligent task routing.** Route tasks to workers based on labels (GPU, region, compliance requirements). Automatic service registry and health monitoring included—no external coordination service needed.
### 🎯 Production Ready
**Not a toy.** Battle-tested error handling with exponential backoff retries, lifecycle hooks (onSuccess, onFailure, onExit), real-time log streaming, email notifications, Prometheus metrics, and OpenTelemetry tracing out of the box.
**Not a toy.** Battle-tested error handling with exponential backoff retries, lifecycle hooks (onSuccess, onFailure, onExit), real-time log streaming, email notifications, Prometheus metrics, and OpenTelemetry tracing out of the box. Built-in user management with role-based access control (RBAC) for team environments.
### 🎨 Modern Web UI
**Beautiful UI that actually helps you debug.** Live log tailing, DAG visualization with Gantt charts, execution history with full lineage, and drill-down into nested sub-workflows. Dark mode included.
@ -252,6 +252,7 @@ Full documentation is available at [docs.dagu.cloud](https://docs.dagu.cloud/).
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `DAGU_AUTH_MODE` | - | Authentication mode: `none`, `builtin`, or `oidc` |
| `DAGU_AUTH_BASIC_USERNAME` | - | Basic auth username |
| `DAGU_AUTH_BASIC_PASSWORD` | - | Basic auth password |
| `DAGU_AUTH_OIDC_CLIENT_ID` | - | OIDC client ID |
@ -261,6 +262,18 @@ Full documentation is available at [docs.dagu.cloud](https://docs.dagu.cloud/).
| `DAGU_AUTH_OIDC_SCOPES` | - | OIDC scopes (comma-separated) |
| `DAGU_AUTH_OIDC_WHITELIST` | - | OIDC email whitelist (comma-separated) |
### Builtin Authentication (RBAC)
When `DAGU_AUTH_MODE=builtin`, a file-based user management system with role-based access control is enabled. Roles: `admin`, `manager`, `operator`, `viewer`.
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `DAGU_AUTH_ADMIN_USERNAME` | `admin` | Initial admin username |
| `DAGU_AUTH_ADMIN_PASSWORD` | (auto-generated) | Initial admin password |
| `DAGU_AUTH_TOKEN_SECRET` | - | JWT token secret for signing (required) |
| `DAGU_AUTH_TOKEN_TTL` | `24h` | JWT token time-to-live |
| `DAGU_USERS_DIR` | `{dataDir}/users` | Directory for user data files |
### UI Configuration
| Environment Variable | Default | Description |
@ -404,7 +417,9 @@ This section outlines the current capabilities of Dagu.
| | Health monitoring | Health check for scheduler & failover | <a href="https://docs.dagu.cloud/configurations/reference#health-check">Health Check</a> |
| | Nested-DAG visualization | Nested DAG visualization with drill down functionality | <a href="https://docs.dagu.cloud/overview/web-ui#nested-dag-visualization">Nested DAG Visualization</a> |
| Security & Governance | Secret injection | Vault/KMS/OIDC ref-only; short-lived tokens | <a href="https://docs.dagu.cloud/writing-workflows/secrets">Secrets</a> |
| | Authentication | Basic auth / OIDC support for Web UI and API | <a href="https://docs.dagu.cloud/configurations/authentication">Authentication</a> |
| | Authentication | Basic auth / OIDC / Builtin (JWT) support for Web UI and API | <a href="https://docs.dagu.cloud/configurations/authentication">Authentication</a> |
| | Role-based access control | Builtin RBAC with admin, manager, operator, viewer roles | |
| | User management | Create, update, delete users with role assignment | |
| | HA (High availability) mode | Control-plane with failover for scheduler / Web UI / Coordinator | <a href="https://docs.dagu.cloud/features/scheduling#high-availability">High Availability</a> |
| Executor types | `jq` | JSON processing with jq queries | <a href="https://docs.dagu.cloud/features/executors/jq">JQ Executor</a> |
| | `ssh` | Remote command execution via SSH | <a href="https://docs.dagu.cloud/features/executors/ssh">SSH Executor</a> |
@ -457,7 +472,7 @@ This section outlines the planned features for Dagu.
| | Inter DAG-run state management | Manage state and data sharing between DAG-runs | 💭 | P0 | |
| | Database backend support | Support for external databases (PostgreSQL, MySQL) instead of filesystem | 💭 | P1 | <a href="https://github.com/dagu-org/dagu/issues/539">#539</a>, <a href="https://github.com/dagu-org/dagu/issues/267">#267</a> |
| Observability | Resource usage monitoring | CPU/Memory/IO usage per DAG/step with live graphs | 💭 | P0 | <a href="https://github.com/dagu-org/dagu/issues/546">#546</a> |
| Security & Governance | Authorization | User management & RBAC with fine-grained permissions | 🏢 | | |
| Security & Governance | Fine-grained permissions | DAG-level and resource-level permissions | 🏢 | | |
| | Resource quotas | CPU time and memory limit | 📋 | P0 | |
| | Audit trail | Immutable events for all manual actions | 🏢 | | |
| | Audit logging | Immutable who/what/when records (WORM) | 🏢 | | |

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,10 @@ tags:
description: "Prometheus-compatible metrics for monitoring Dagu operations"
- name: "queues"
description: "Operations for managing and monitoring execution queues"
- name: "auth"
description: "Authentication operations (login, logout, token management)"
- name: "users"
description: "User management operations (CRUD, password management)"
paths:
/health:
@ -52,6 +56,385 @@ paths:
default:
description: "Unexpected error"
# ============================================================================
# Authentication Endpoints
# ============================================================================
/auth/login:
post:
summary: "Authenticate user and obtain JWT token"
description: "Authenticates a user with username and password, returns a JWT token on success"
operationId: "login"
tags:
- "auth"
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/LoginRequest"
responses:
"200":
description: "Authentication successful"
content:
application/json:
schema:
$ref: "#/components/schemas/LoginResponse"
"401":
description: "Invalid credentials"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/auth/me:
get:
summary: "Get current authenticated user"
description: "Returns information about the currently authenticated user"
operationId: "getCurrentUser"
tags:
- "auth"
responses:
"200":
description: "Current user information"
content:
application/json:
schema:
$ref: "#/components/schemas/UserResponse"
"401":
description: "Not authenticated"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/auth/change-password:
post:
summary: "Change current user's password"
description: "Allows the authenticated user to change their own password"
operationId: "changePassword"
tags:
- "auth"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ChangePasswordRequest"
responses:
"200":
description: "Password changed successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/SuccessResponse"
"400":
description: "Invalid request (e.g., weak password)"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: "Not authenticated or wrong current password"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
# ============================================================================
# User Management Endpoints (Admin only)
# ============================================================================
/users:
get:
summary: "List all users"
description: "Returns a list of all users. Requires admin role."
operationId: "listUsers"
tags:
- "users"
responses:
"200":
description: "List of users"
content:
application/json:
schema:
$ref: "#/components/schemas/UsersListResponse"
"401":
description: "Not authenticated"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: "Forbidden - requires admin role"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
post:
summary: "Create a new user"
description: "Creates a new user account. Requires admin role."
operationId: "createUser"
tags:
- "users"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserRequest"
responses:
"201":
description: "User created successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/UserResponse"
"400":
description: "Invalid request (e.g., weak password, invalid role)"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: "Not authenticated"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: "Forbidden - requires admin role"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: "Conflict - username already exists"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/users/{userId}:
get:
summary: "Get user by ID"
description: "Returns a specific user by their ID. Requires admin role."
operationId: "getUser"
tags:
- "users"
parameters:
- $ref: "#/components/parameters/UserId"
responses:
"200":
description: "User details"
content:
application/json:
schema:
$ref: "#/components/schemas/UserResponse"
"401":
description: "Not authenticated"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: "Forbidden - requires admin role"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: "User not found"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
patch:
summary: "Update user"
description: "Updates a user's information. Requires admin role."
operationId: "updateUser"
tags:
- "users"
parameters:
- $ref: "#/components/parameters/UserId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateUserRequest"
responses:
"200":
description: "User updated successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/UserResponse"
"400":
description: "Invalid request"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: "Not authenticated"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: "Forbidden - requires admin role"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: "User not found"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: "Conflict - username already exists"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
delete:
summary: "Delete user"
description: "Deletes a user account. Requires admin role. Cannot delete yourself."
operationId: "deleteUser"
tags:
- "users"
parameters:
- $ref: "#/components/parameters/UserId"
responses:
"204":
description: "User deleted successfully"
"401":
description: "Not authenticated"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: "Forbidden - requires admin role or cannot delete self"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: "User not found"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/users/{userId}/reset-password:
post:
summary: "Reset user's password"
description: "Resets a user's password to a new value. Requires admin role."
operationId: "resetUserPassword"
tags:
- "users"
parameters:
- $ref: "#/components/parameters/UserId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ResetPasswordRequest"
responses:
"200":
description: "Password reset successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/SuccessResponse"
"400":
description: "Invalid request (e.g., weak password)"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: "Not authenticated"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: "Forbidden - requires admin role"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: "User not found"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
default:
description: "Unexpected error"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/workers:
get:
summary: "List distributed workers"
@ -1673,6 +2056,15 @@ components:
minimum: 1
default: 1
UserId:
name: userId
in: path
description: unique identifier of the user
required: true
schema:
type: string
minLength: 1
PerPage:
name: perPage
in: query
@ -2870,6 +3262,169 @@ components:
- timestamp
- value
# ============================================================================
# Authentication & User Management Schemas
# ============================================================================
UserRole:
type: string
description: "User role determining access permissions. admin: full access including user management, manager: DAG CRUD and execution, operator: DAG execution only, viewer: read-only"
enum:
- admin
- manager
- operator
- viewer
LoginRequest:
type: object
description: "Request body for user login"
properties:
username:
type: string
description: "User's username"
minLength: 1
password:
type: string
description: "User's password"
minLength: 1
required:
- username
- password
LoginResponse:
type: object
description: "Response containing authentication token"
properties:
token:
type: string
description: "JWT authentication token"
expiresAt:
type: string
format: date-time
description: "Token expiration timestamp"
user:
$ref: "#/components/schemas/User"
required:
- token
- expiresAt
- user
ChangePasswordRequest:
type: object
description: "Request body for changing password"
properties:
currentPassword:
type: string
description: "Current password for verification"
minLength: 1
newPassword:
type: string
description: "New password to set"
minLength: 8
required:
- currentPassword
- newPassword
ResetPasswordRequest:
type: object
description: "Request body for admin password reset"
properties:
newPassword:
type: string
description: "New password to set for the user"
minLength: 8
required:
- newPassword
CreateUserRequest:
type: object
description: "Request body for creating a new user"
properties:
username:
type: string
description: "Unique username"
minLength: 1
maxLength: 64
password:
type: string
description: "User's password"
minLength: 8
role:
$ref: "#/components/schemas/UserRole"
required:
- username
- password
- role
UpdateUserRequest:
type: object
description: "Request body for updating a user"
properties:
username:
type: string
description: "New username (must be unique)"
minLength: 1
maxLength: 64
role:
$ref: "#/components/schemas/UserRole"
User:
type: object
description: "User information"
properties:
id:
type: string
description: "Unique user identifier"
username:
type: string
description: "User's username"
role:
$ref: "#/components/schemas/UserRole"
createdAt:
type: string
format: date-time
description: "Account creation timestamp"
updatedAt:
type: string
format: date-time
description: "Last update timestamp"
required:
- id
- username
- role
- createdAt
- updatedAt
UserResponse:
type: object
description: "Response containing user information"
properties:
user:
$ref: "#/components/schemas/User"
required:
- user
UsersListResponse:
type: object
description: "Response containing list of users"
properties:
users:
type: array
items:
$ref: "#/components/schemas/User"
required:
- users
SuccessResponse:
type: object
description: "Generic success response"
properties:
message:
type: string
description: "Success message"
required:
- message
# Apply security requirements globally
security:
- apiToken: []

View File

@ -23,6 +23,13 @@ services:
- DAGU_DEBUG=true # More verbose logs during dev
# Paths
- DAGU_DAGS_DIR=/var/lib/dagu/dags
# Builtin authentication (RBAC) - CHANGE TOKEN_SECRET IN PRODUCTION
- DAGU_AUTH_MODE=builtin
- DAGU_AUTH_TOKEN_SECRET=CHANGE_ME_TO_A_SECURE_RANDOM_STRING
# Admin credentials: password auto-generated on first run, printed to stdout
# - DAGU_AUTH_ADMIN_USERNAME=admin # default is 'admin'
# - DAGU_AUTH_ADMIN_PASSWORD= # set to use a specific password
# - DAGU_AUTH_TOKEN_TTL=24h # default is 24h
# Timezone / base path (optional)
# - DAGU_TZ=UTC
# - DAGU_BASE_PATH=/dagu
@ -43,7 +50,9 @@ services:
# command: ["dagu", "coordinator"]
# command: ["dagu", "worker"]
# Example: enable basic auth in dev (uncomment and set values)
# Alternative: basic auth (simpler, no user management)
# To use basic auth instead of builtin auth, comment out
# DAGU_AUTH_MODE and DAGU_AUTH_TOKEN_SECRET above, then uncomment:
# environment:
# - DAGU_AUTH_BASIC_USERNAME=dev
# - DAGU_AUTH_BASIC_PASSWORD=devpass

View File

@ -82,6 +82,13 @@ services:
- DAGU_HOST=0.0.0.0
- DAGU_PORT=8080
- DAGU_DAGS_DIR=/var/lib/dagu/dags
# Builtin authentication (RBAC) - CHANGE TOKEN_SECRET IN PRODUCTION
- DAGU_AUTH_MODE=builtin
- DAGU_AUTH_TOKEN_SECRET=CHANGE_ME_TO_A_SECURE_RANDOM_STRING
# Admin credentials: password auto-generated on first run, printed to stdout
# - DAGU_AUTH_ADMIN_USERNAME=admin # default is 'admin'
# - DAGU_AUTH_ADMIN_PASSWORD= # set to use a specific password
# - DAGU_AUTH_TOKEN_TTL=24h # default is 24h
# If behind a proxy, set base path
# - DAGU_BASE_PATH=/dagu
depends_on:

26
internal/auth/context.go Normal file
View File

@ -0,0 +1,26 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import "context"
// contextKey is a private type for context keys to avoid collisions.
type contextKey string
const (
// userContextKey is the key for storing the authenticated user in context.
userContextKey contextKey = "auth_user"
)
// WithUser returns a new context that carries the provided user value.
func WithUser(ctx context.Context, user *User) context.Context {
return context.WithValue(ctx, userContextKey, user)
}
// UserFromContext retrieves the authenticated user from the context.
// It returns the user and true if a *User value is present for the package's userContextKey, or nil and false otherwise.
func UserFromContext(ctx context.Context) (*User, bool) {
user, ok := ctx.Value(userContextKey).(*User)
return user, ok
}

77
internal/auth/role.go Normal file
View File

@ -0,0 +1,77 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import "fmt"
// Role represents a user's role in the system.
// Roles determine what actions a user can perform.
//
// Role hierarchy (most to least privileged):
// - admin: Full system access including user management
// - manager: Can create, edit, delete, run, and stop DAGs
// - operator: Can run and stop DAGs (execute only)
// - viewer: Read-only access to DAGs and status
type Role string
const (
// RoleAdmin has full access to all resources including user management.
RoleAdmin Role = "admin"
// RoleManager can create, edit, delete, run, and stop DAGs.
RoleManager Role = "manager"
// RoleOperator can run and stop DAGs (execute only, no edit).
RoleOperator Role = "operator"
// RoleViewer can only view DAGs and execution history (read-only).
RoleViewer Role = "viewer"
)
// allRoles contains all valid roles for iteration and validation.
var allRoles = []Role{RoleAdmin, RoleManager, RoleOperator, RoleViewer}
// AllRoles returns a copy of all valid roles.
func AllRoles() []Role {
roles := make([]Role, len(allRoles))
copy(roles, allRoles)
return roles
}
// Valid returns true if the role is a known valid role.
func (r Role) Valid() bool {
switch r {
case RoleAdmin, RoleManager, RoleOperator, RoleViewer:
return true
}
return false
}
// String returns the string representation of the role.
func (r Role) String() string {
return string(r)
}
// CanWrite returns true if the role can create, edit, or delete DAGs.
func (r Role) CanWrite() bool {
return r == RoleAdmin || r == RoleManager
}
// CanExecute returns true if the role can run or stop DAGs.
func (r Role) CanExecute() bool {
return r == RoleAdmin || r == RoleManager || r == RoleOperator
}
// IsAdmin returns true if the role has administrative privileges (user management).
func (r Role) IsAdmin() bool {
return r == RoleAdmin
}
// ParseRole converts a string to a Role.
// ParseRole converts the input string to a Role and verifies it is one of the known roles.
// If the input is not "admin", "manager", "operator", or "viewer", it returns an error describing the valid options.
func ParseRole(s string) (Role, error) {
role := Role(s)
if !role.Valid() {
return "", fmt.Errorf("invalid role: %q, must be one of: admin, manager, operator, viewer", s)
}
return role, nil
}

136
internal/auth/role_test.go Normal file
View File

@ -0,0 +1,136 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"testing"
)
func TestRole_Valid(t *testing.T) {
tests := []struct {
role Role
valid bool
}{
{RoleAdmin, true},
{RoleManager, true},
{RoleOperator, true},
{RoleViewer, true},
{Role("invalid"), false},
{Role(""), false},
{Role("ADMIN"), false}, // case sensitive
{Role("editor"), false}, // old role name
}
for _, tt := range tests {
t.Run(string(tt.role), func(t *testing.T) {
if got := tt.role.Valid(); got != tt.valid {
t.Errorf("Role(%q).Valid() = %v, want %v", tt.role, got, tt.valid)
}
})
}
}
func TestRole_CanWrite(t *testing.T) {
tests := []struct {
role Role
canWrite bool
}{
{RoleAdmin, true},
{RoleManager, true},
{RoleOperator, false},
{RoleViewer, false},
}
for _, tt := range tests {
t.Run(string(tt.role), func(t *testing.T) {
if got := tt.role.CanWrite(); got != tt.canWrite {
t.Errorf("Role(%q).CanWrite() = %v, want %v", tt.role, got, tt.canWrite)
}
})
}
}
func TestRole_CanExecute(t *testing.T) {
tests := []struct {
role Role
canExecute bool
}{
{RoleAdmin, true},
{RoleManager, true},
{RoleOperator, true},
{RoleViewer, false},
}
for _, tt := range tests {
t.Run(string(tt.role), func(t *testing.T) {
if got := tt.role.CanExecute(); got != tt.canExecute {
t.Errorf("Role(%q).CanExecute() = %v, want %v", tt.role, got, tt.canExecute)
}
})
}
}
func TestRole_IsAdmin(t *testing.T) {
tests := []struct {
role Role
isAdmin bool
}{
{RoleAdmin, true},
{RoleManager, false},
{RoleOperator, false},
{RoleViewer, false},
}
for _, tt := range tests {
t.Run(string(tt.role), func(t *testing.T) {
if got := tt.role.IsAdmin(); got != tt.isAdmin {
t.Errorf("Role(%q).IsAdmin() = %v, want %v", tt.role, got, tt.isAdmin)
}
})
}
}
func TestParseRole(t *testing.T) {
tests := []struct {
input string
want Role
wantErr bool
}{
{"admin", RoleAdmin, false},
{"manager", RoleManager, false},
{"operator", RoleOperator, false},
{"viewer", RoleViewer, false},
{"invalid", "", true},
{"", "", true},
{"ADMIN", "", true},
{"editor", "", true}, // old role name
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseRole(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseRole(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ParseRole(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestAllRoles(t *testing.T) {
roles := AllRoles()
if len(roles) != 4 {
t.Errorf("AllRoles() returned %d roles, want 4", len(roles))
}
// Ensure modifying returned slice doesn't affect internal state
roles[0] = "modified"
originalRoles := AllRoles()
if originalRoles[0] == "modified" {
t.Error("AllRoles() returned a reference to internal state")
}
}

52
internal/auth/store.go Normal file
View File

@ -0,0 +1,52 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"context"
"errors"
)
// Common errors for user store operations.
var (
// ErrUserNotFound is returned when a user cannot be found.
ErrUserNotFound = errors.New("user not found")
// ErrUserAlreadyExists is returned when attempting to create a user
// with a username that already exists.
ErrUserAlreadyExists = errors.New("user already exists")
// ErrInvalidUsername is returned when the username is invalid.
ErrInvalidUsername = errors.New("invalid username")
// ErrInvalidUserID is returned when the user ID is invalid.
ErrInvalidUserID = errors.New("invalid user ID")
)
// UserStore defines the interface for user persistence operations.
// Implementations must be safe for concurrent use.
type UserStore interface {
// Create stores a new user.
// Returns ErrUserAlreadyExists if a user with the same username exists.
Create(ctx context.Context, user *User) error
// GetByID retrieves a user by their unique ID.
// Returns ErrUserNotFound if the user does not exist.
GetByID(ctx context.Context, id string) (*User, error)
// GetByUsername retrieves a user by their username.
// Returns ErrUserNotFound if the user does not exist.
GetByUsername(ctx context.Context, username string) (*User, error)
// List returns all users in the store.
List(ctx context.Context) ([]*User, error)
// Update modifies an existing user.
// Returns ErrUserNotFound if the user does not exist.
Update(ctx context.Context, user *User) error
// Delete removes a user by their ID.
// Returns ErrUserNotFound if the user does not exist.
Delete(ctx context.Context, id string) error
// Count returns the total number of users.
Count(ctx context.Context) (int64, error)
}

76
internal/auth/user.go Normal file
View File

@ -0,0 +1,76 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"time"
"github.com/google/uuid"
)
// User represents a user in the system.
type User struct {
// ID is the unique identifier for the user (UUID).
ID string `json:"id"`
// Username is the unique login name.
Username string `json:"username"`
// PasswordHash is the bcrypt hash of the password.
// Excluded from JSON serialization for security.
PasswordHash string `json:"-"`
// Role determines the user's permissions.
Role Role `json:"role"`
// CreatedAt is the timestamp when the user was created.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt is the timestamp when the user was last modified.
UpdatedAt time.Time `json:"updated_at"`
}
// NewUser creates a User with a new UUID and sets CreatedAt and UpdatedAt to the current UTC time.
// The provided username, passwordHash, and role are assigned to the corresponding fields.
func NewUser(username string, passwordHash string, role Role) *User {
now := time.Now().UTC()
return &User{
ID: uuid.New().String(),
Username: username,
PasswordHash: passwordHash,
Role: role,
CreatedAt: now,
UpdatedAt: now,
}
}
// UserForStorage is used for JSON serialization to persistent storage.
// It includes the password hash which is excluded from the regular User JSON.
type UserForStorage struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
Role Role `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToStorage converts a User to UserForStorage for persistence.
func (u *User) ToStorage() *UserForStorage {
return &UserForStorage{
ID: u.ID,
Username: u.Username,
PasswordHash: u.PasswordHash,
Role: u.Role,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// ToUser converts UserForStorage back to User.
func (s *UserForStorage) ToUser() *User {
return &User{
ID: s.ID,
Username: s.Username,
PasswordHash: s.PasswordHash,
Role: s.Role,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
}

View File

@ -196,7 +196,7 @@ func (c *Context) NewServer(rs *resource.Service) (*frontend.Server, error) {
mr := telemetry.NewRegistry(collector)
return frontend.NewServer(c.Config, dr, c.DAGRunStore, c.QueueStore, c.ProcStore, c.DAGRunMgr, cc, c.ServiceRegistry, mr, rs), nil
return frontend.NewServer(c.Config, dr, c.DAGRunStore, c.QueueStore, c.ProcStore, c.DAGRunMgr, cc, c.ServiceRegistry, mr, rs)
}
// NewCoordinatorClient creates a new coordinator client using the global peer configuration.

View File

@ -127,11 +127,43 @@ const (
PermissionRunDAGs Permission = "run_dags"
)
// AuthMode represents the authentication mode.
type AuthMode string
const (
// AuthModeNone disables authentication.
AuthModeNone AuthMode = "none"
// AuthModeBuiltin enables builtin user management with RBAC.
AuthModeBuiltin AuthMode = "builtin"
// AuthModeOIDC enables OIDC authentication.
AuthModeOIDC AuthMode = "oidc"
)
// Auth represents the authentication configuration
type Auth struct {
Basic AuthBasic
Token AuthToken
OIDC AuthOIDC
Mode AuthMode
Basic AuthBasic
Token AuthToken
OIDC AuthOIDC
Builtin AuthBuiltin
}
// AuthBuiltin represents the builtin authentication configuration
type AuthBuiltin struct {
Admin AdminConfig
Token TokenConfig
}
// AdminConfig represents the initial admin user configuration
type AdminConfig struct {
Username string
Password string
}
// TokenConfig represents the JWT token configuration
type TokenConfig struct {
Secret string
TTL time.Duration
}
// AuthBasic represents the basic authentication configuration
@ -167,6 +199,7 @@ type PathsConfig struct {
QueueDir string
ProcDir string
ServiceRegistryDir string // Directory for service registry files
UsersDir string // Directory for user data (builtin auth)
ConfigFileUsed string // Path to the configuration file used to load settings
}
@ -283,5 +316,39 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid max dashboard page limit: %d", c.UI.MaxDashboardPageLimit)
}
// Validate builtin auth configuration
if err := c.validateBuiltinAuth(); err != nil {
return err
}
return nil
}
// validateBuiltinAuth validates the builtin authentication configuration.
func (c *Config) validateBuiltinAuth() error {
if c.Server.Auth.Mode != AuthModeBuiltin {
return nil
}
// When builtin auth is enabled, users directory must be set
if c.Paths.UsersDir == "" {
return fmt.Errorf("builtin auth requires paths.usersDir to be set")
}
// Admin username must be set (has default, but check anyway)
if c.Server.Auth.Builtin.Admin.Username == "" {
return fmt.Errorf("builtin auth requires admin username to be set")
}
// Token secret must be set for JWT signing
if c.Server.Auth.Builtin.Token.Secret == "" {
return fmt.Errorf("builtin auth requires token secret to be set (auth.builtin.token.secret or AUTH_TOKEN_SECRET env var)")
}
// Token TTL must be positive
if c.Server.Auth.Builtin.Token.TTL <= 0 {
return fmt.Errorf("builtin auth requires a positive token TTL")
}
return nil
}

View File

@ -193,4 +193,121 @@ func TestConfig_Validate(t *testing.T) {
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("BuiltinAuth_MissingUsersDir", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Server: Server{
Port: 8080,
Auth: Auth{
Mode: AuthModeBuiltin,
Builtin: AuthBuiltin{
Admin: AdminConfig{Username: "admin"},
Token: TokenConfig{Secret: "secret", TTL: 1},
},
},
},
Paths: PathsConfig{
UsersDir: "",
},
UI: UI{
MaxDashboardPageLimit: 1,
},
}
err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "usersDir")
})
t.Run("BuiltinAuth_MissingTokenSecret", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Server: Server{
Port: 8080,
Auth: Auth{
Mode: AuthModeBuiltin,
Builtin: AuthBuiltin{
Admin: AdminConfig{Username: "admin"},
Token: TokenConfig{Secret: "", TTL: 1},
},
},
},
Paths: PathsConfig{
UsersDir: "/tmp/users",
},
UI: UI{
MaxDashboardPageLimit: 1,
},
}
err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "token secret")
})
t.Run("BuiltinAuth_MissingAdminUsername", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Server: Server{
Port: 8080,
Auth: Auth{
Mode: AuthModeBuiltin,
Builtin: AuthBuiltin{
Admin: AdminConfig{Username: ""},
Token: TokenConfig{Secret: "secret", TTL: 1},
},
},
},
Paths: PathsConfig{
UsersDir: "/tmp/users",
},
UI: UI{
MaxDashboardPageLimit: 1,
},
}
err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "admin username")
})
t.Run("BuiltinAuth_ValidConfig", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Server: Server{
Port: 8080,
Auth: Auth{
Mode: AuthModeBuiltin,
Builtin: AuthBuiltin{
Admin: AdminConfig{Username: "admin"},
Token: TokenConfig{Secret: "secret", TTL: 1},
},
},
},
Paths: PathsConfig{
UsersDir: "/tmp/users",
},
UI: UI{
MaxDashboardPageLimit: 1,
},
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("BuiltinAuth_SkippedForOtherModes", func(t *testing.T) {
t.Parallel()
// When auth mode is not builtin, validation should pass even without builtin config
cfg := &Config{
Server: Server{
Port: 8080,
Auth: Auth{
Mode: AuthModeNone,
},
},
UI: UI{
MaxDashboardPageLimit: 1,
},
}
err := cfg.Validate()
require.NoError(t, err)
})
}

View File

@ -157,9 +157,30 @@ type PeerDef struct {
// AuthDef holds the authentication configuration for the application.
type AuthDef struct {
Basic *AuthBasicDef `mapstructure:"basic"`
Token *AuthTokenDef `mapstructure:"token"`
OIDC *AuthOIDCDef `mapstructure:"oidc"`
// Mode specifies the authentication mode: "none", "builtin", or "oidc"
Mode string `mapstructure:"mode"`
Basic *AuthBasicDef `mapstructure:"basic"`
Token *AuthTokenDef `mapstructure:"token"`
OIDC *AuthOIDCDef `mapstructure:"oidc"`
Builtin *AuthBuiltinDef `mapstructure:"builtin"`
}
// AuthBuiltinDef represents the builtin authentication configuration
type AuthBuiltinDef struct {
Admin *AdminConfigDef `mapstructure:"admin"`
Token *TokenConfigDef `mapstructure:"token"`
}
// AdminConfigDef represents the initial admin user configuration
type AdminConfigDef struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// TokenConfigDef represents the JWT token configuration
type TokenConfigDef struct {
Secret string `mapstructure:"secret"`
TTL string `mapstructure:"ttl"`
}
// AuthBasicDef represents the basic authentication configuration
@ -195,6 +216,7 @@ type PathsDef struct {
QueueDir string `mapstructure:"queueDir"`
ProcDir string `mapstructure:"procDir"`
ServiceRegistryDir string `mapstructure:"serviceRegistryDir"`
UsersDir string `mapstructure:"usersDir"`
}
// UIDef holds the user interface configuration settings.

View File

@ -205,6 +205,9 @@ func (l *ConfigLoader) buildConfig(def Definition) (*Config, error) {
// Process authentication settings.
if def.Auth != nil {
// Set auth mode
cfg.Server.Auth.Mode = AuthMode(def.Auth.Mode)
if def.Auth.Basic != nil {
cfg.Server.Auth.Basic.Username = def.Auth.Basic.Username
cfg.Server.Auth.Basic.Password = def.Auth.Basic.Password
@ -220,6 +223,41 @@ func (l *ConfigLoader) buildConfig(def Definition) (*Config, error) {
cfg.Server.Auth.OIDC.Scopes = def.Auth.OIDC.Scopes
cfg.Server.Auth.OIDC.Whitelist = def.Auth.OIDC.Whitelist
}
if def.Auth.Builtin != nil {
if def.Auth.Builtin.Admin != nil {
cfg.Server.Auth.Builtin.Admin.Username = def.Auth.Builtin.Admin.Username
cfg.Server.Auth.Builtin.Admin.Password = def.Auth.Builtin.Admin.Password
}
if def.Auth.Builtin.Token != nil {
cfg.Server.Auth.Builtin.Token.Secret = def.Auth.Builtin.Token.Secret
if def.Auth.Builtin.Token.TTL != "" {
if duration, err := time.ParseDuration(def.Auth.Builtin.Token.TTL); err == nil {
cfg.Server.Auth.Builtin.Token.TTL = duration
} else {
l.warnings = append(l.warnings, fmt.Sprintf("Invalid auth.builtin.token.ttl value: %s", def.Auth.Builtin.Token.TTL))
}
}
}
}
}
// Set default token TTL if not specified
if cfg.Server.Auth.Builtin.Token.TTL <= 0 {
cfg.Server.Auth.Builtin.Token.TTL = 24 * time.Hour
}
// Set default admin username if not specified
if cfg.Server.Auth.Builtin.Admin.Username == "" {
cfg.Server.Auth.Builtin.Admin.Username = "admin"
}
// Auto-detect auth mode if not explicitly set
// If OIDC is configured (clientId, clientSecret, and issuer are set), default to OIDC mode
if cfg.Server.Auth.Mode == "" {
oidc := cfg.Server.Auth.OIDC
if oidc.ClientId != "" && oidc.ClientSecret != "" && oidc.Issuer != "" {
cfg.Server.Auth.Mode = AuthModeOIDC
l.warnings = append(l.warnings, fmt.Sprintf("Auth mode auto-detected as 'oidc' based on OIDC configuration (issuer: %s)", oidc.Issuer))
}
}
// Normalize the BasePath value for proper URL construction.
@ -238,6 +276,7 @@ func (l *ConfigLoader) buildConfig(def Definition) (*Config, error) {
cfg.Paths.QueueDir = fileutil.ResolvePathOrBlank(def.Paths.QueueDir)
cfg.Paths.ProcDir = fileutil.ResolvePathOrBlank(def.Paths.ProcDir)
cfg.Paths.ServiceRegistryDir = fileutil.ResolvePathOrBlank(def.Paths.ServiceRegistryDir)
cfg.Paths.UsersDir = fileutil.ResolvePathOrBlank(def.Paths.UsersDir)
}
// Set UI configuration if provided.
@ -400,6 +439,9 @@ func (l *ConfigLoader) buildConfig(def Definition) (*Config, error) {
if cfg.Paths.ServiceRegistryDir == "" {
cfg.Paths.ServiceRegistryDir = filepath.Join(cfg.Paths.DataDir, "service-registry")
}
if cfg.Paths.UsersDir == "" {
cfg.Paths.UsersDir = filepath.Join(cfg.Paths.DataDir, "users")
}
// Ensure the executable path is set.
if cfg.Paths.Executable == "" {
@ -597,7 +639,7 @@ func setViperDefaultValues(paths Paths) {
// bindEnvironmentVariables binds configuration keys to the environment variable names used by Viper.
// It registers current and legacy environment names for server, global, scheduler, UI, authentication
// (including OIDC and legacy keys), TLS, file paths (with path normalization where appropriate),
// coordinator, worker, peer, queues, and monitoring settings.
// (e.g., path normalization) so environment values override config settings.
func bindEnvironmentVariables() {
// Server configurations
bindEnv("logFormat", "LOG_FORMAT")
@ -651,6 +693,13 @@ func bindEnvironmentVariables() {
bindEnv("auth.basic.password", "BASICAUTH_PASSWORD")
bindEnv("auth.token.value", "AUTHTOKEN")
// Authentication configurations (builtin)
bindEnv("auth.mode", "AUTH_MODE")
bindEnv("auth.builtin.admin.username", "AUTH_ADMIN_USERNAME")
bindEnv("auth.builtin.admin.password", "AUTH_ADMIN_PASSWORD")
bindEnv("auth.builtin.token.secret", "AUTH_TOKEN_SECRET")
bindEnv("auth.builtin.token.ttl", "AUTH_TOKEN_TTL")
// TLS configurations
bindEnv("tls.certFile", "CERT_FILE")
bindEnv("tls.keyFile", "KEY_FILE")
@ -668,6 +717,7 @@ func bindEnvironmentVariables() {
bindEnv("paths.procDir", "PROC_DIR", asPath())
bindEnv("paths.queueDir", "QUEUE_DIR", asPath())
bindEnv("paths.serviceRegistryDir", "SERVICE_REGISTRY_DIR", asPath())
bindEnv("paths.usersDir", "USERS_DIR", asPath())
// UI customization
bindEnv("latestStatusToday", "LATEST_STATUS_TODAY")

View File

@ -151,6 +151,7 @@ func TestLoad_Env(t *testing.T) {
APIBasePath: "/test/api",
Headless: true,
Auth: Auth{
Mode: AuthModeOIDC, // Auto-detected from OIDC config
Basic: AuthBasic{Username: "testuser", Password: "testpass"},
Token: AuthToken{Value: "test-token-123"},
OIDC: AuthOIDC{
@ -159,6 +160,10 @@ func TestLoad_Env(t *testing.T) {
Issuer: "https://auth.example.com",
Scopes: []string{"openid", "profile", "email"},
},
Builtin: AuthBuiltin{
Admin: AdminConfig{Username: "admin"},
Token: TokenConfig{TTL: 24 * time.Hour},
},
},
TLS: &TLSConfig{
CertFile: filepath.Join(testPaths, "cert.pem"),
@ -180,6 +185,7 @@ func TestLoad_Env(t *testing.T) {
ProcDir: filepath.Join(testPaths, "proc"),
QueueDir: filepath.Join(testPaths, "queue"),
ServiceRegistryDir: filepath.Join(testPaths, "service-registry"),
UsersDir: filepath.Join(testPaths, "data", "users"), // Derived from DataDir
},
UI: UI{
LogEncodingCharset: "iso-8859-1",
@ -211,6 +217,7 @@ func TestLoad_Env(t *testing.T) {
Retention: 24 * time.Hour,
Interval: 5 * time.Second,
},
Warnings: []string{"Auth mode auto-detected as 'oidc' based on OIDC configuration (issuer: https://auth.example.com)"},
}
assert.Equal(t, expected, cfg)
@ -356,6 +363,7 @@ scheduler:
Headless: true,
LatestStatusToday: true,
Auth: Auth{
Mode: AuthModeOIDC, // Auto-detected from OIDC config
Basic: AuthBasic{Username: "admin", Password: "secret"},
Token: AuthToken{Value: "api-token"},
OIDC: AuthOIDC{
@ -366,6 +374,10 @@ scheduler:
Scopes: []string{"openid", "profile", "email"},
Whitelist: []string{"user@example.com"},
},
Builtin: AuthBuiltin{
Admin: AdminConfig{Username: "admin"},
Token: TokenConfig{TTL: 24 * time.Hour},
},
},
TLS: &TLSConfig{
CertFile: "/path/to/cert.pem",
@ -405,6 +417,7 @@ scheduler:
ProcDir: "/var/dagu/data/proc",
QueueDir: "/var/dagu/data/queue",
ServiceRegistryDir: "/var/dagu/data/service-registry",
UsersDir: "/var/dagu/data/users",
},
UI: UI{
LogEncodingCharset: "iso-8859-1",
@ -445,6 +458,7 @@ scheduler:
Retention: 24 * time.Hour,
Interval: 5 * time.Second,
},
Warnings: []string{"Auth mode auto-detected as 'oidc' based on OIDC configuration (issuer: https://accounts.example.com)"},
}
assert.Equal(t, expected, cfg)
@ -499,6 +513,7 @@ paths:
assert.Equal(t, "/custom/data/proc", cfg.Paths.ProcDir)
assert.Equal(t, "/custom/data/queue", cfg.Paths.QueueDir)
assert.Equal(t, "/custom/data/service-registry", cfg.Paths.ServiceRegistryDir)
assert.Equal(t, "/custom/data/users", cfg.Paths.UsersDir)
}
func TestLoad_EdgeCases_Errors(t *testing.T) {
@ -816,3 +831,131 @@ monitoring:
assert.Equal(t, 5*time.Second, cfg.Monitoring.Interval)
})
}
func TestLoad_AuthMode(t *testing.T) {
t.Run("AuthModeNone", func(t *testing.T) {
cfg := loadFromYAML(t, `
auth:
mode: "none"
`)
assert.Equal(t, AuthModeNone, cfg.Server.Auth.Mode)
})
t.Run("AuthModeBuiltin", func(t *testing.T) {
cfg := loadFromYAML(t, `
auth:
mode: "builtin"
builtin:
admin:
username: "admin"
password: "secretpass123"
token:
secret: "my-jwt-secret-key"
ttl: "12h"
`)
assert.Equal(t, AuthModeBuiltin, cfg.Server.Auth.Mode)
assert.Equal(t, "admin", cfg.Server.Auth.Builtin.Admin.Username)
assert.Equal(t, "secretpass123", cfg.Server.Auth.Builtin.Admin.Password)
assert.Equal(t, "my-jwt-secret-key", cfg.Server.Auth.Builtin.Token.Secret)
assert.Equal(t, 12*time.Hour, cfg.Server.Auth.Builtin.Token.TTL)
})
t.Run("AuthModeOIDC", func(t *testing.T) {
cfg := loadFromYAML(t, `
auth:
mode: "oidc"
oidc:
clientId: "my-client-id"
clientSecret: "my-client-secret"
issuer: "https://auth.example.com"
scopes:
- "openid"
- "profile"
`)
assert.Equal(t, AuthModeOIDC, cfg.Server.Auth.Mode)
assert.Equal(t, "my-client-id", cfg.Server.Auth.OIDC.ClientId)
assert.Equal(t, "my-client-secret", cfg.Server.Auth.OIDC.ClientSecret)
assert.Equal(t, "https://auth.example.com", cfg.Server.Auth.OIDC.Issuer)
assert.Equal(t, []string{"openid", "profile"}, cfg.Server.Auth.OIDC.Scopes)
})
t.Run("AuthModeFromEnv", func(t *testing.T) {
cfg := loadWithEnv(t, "# empty", map[string]string{
"DAGU_AUTH_MODE": "builtin",
"DAGU_AUTH_TOKEN_SECRET": "test-secret",
"DAGU_PATHS_USERS_DIR": t.TempDir(),
})
assert.Equal(t, AuthModeBuiltin, cfg.Server.Auth.Mode)
})
t.Run("AuthModeDefaultEmpty", func(t *testing.T) {
cfg := loadFromYAML(t, "# empty")
assert.Equal(t, AuthMode(""), cfg.Server.Auth.Mode)
})
}
func TestLoad_AuthBuiltin(t *testing.T) {
t.Run("FromYAML", func(t *testing.T) {
cfg := loadFromYAML(t, `
auth:
mode: "builtin"
builtin:
admin:
username: "superadmin"
password: "supersecret123"
token:
secret: "jwt-signing-secret"
ttl: "24h"
`)
assert.Equal(t, AuthModeBuiltin, cfg.Server.Auth.Mode)
assert.Equal(t, "superadmin", cfg.Server.Auth.Builtin.Admin.Username)
assert.Equal(t, "supersecret123", cfg.Server.Auth.Builtin.Admin.Password)
assert.Equal(t, "jwt-signing-secret", cfg.Server.Auth.Builtin.Token.Secret)
assert.Equal(t, 24*time.Hour, cfg.Server.Auth.Builtin.Token.TTL)
})
t.Run("FromEnv", func(t *testing.T) {
cfg := loadWithEnv(t, "# empty", map[string]string{
"DAGU_AUTH_MODE": "builtin",
"DAGU_AUTH_ADMIN_USERNAME": "envadmin",
"DAGU_AUTH_ADMIN_PASSWORD": "envpassword123",
"DAGU_AUTH_TOKEN_SECRET": "env-jwt-secret",
"DAGU_AUTH_TOKEN_TTL": "48h",
})
assert.Equal(t, AuthModeBuiltin, cfg.Server.Auth.Mode)
assert.Equal(t, "envadmin", cfg.Server.Auth.Builtin.Admin.Username)
assert.Equal(t, "envpassword123", cfg.Server.Auth.Builtin.Admin.Password)
assert.Equal(t, "env-jwt-secret", cfg.Server.Auth.Builtin.Token.Secret)
assert.Equal(t, 48*time.Hour, cfg.Server.Auth.Builtin.Token.TTL)
})
t.Run("EmptyPasswordAllowed", func(t *testing.T) {
cfg := loadFromYAML(t, `
auth:
mode: "builtin"
builtin:
admin:
username: "admin"
password: ""
token:
secret: "secret"
ttl: "1h"
`)
assert.Equal(t, "admin", cfg.Server.Auth.Builtin.Admin.Username)
assert.Equal(t, "", cfg.Server.Auth.Builtin.Admin.Password)
})
t.Run("DefaultTTL", func(t *testing.T) {
cfg := loadFromYAML(t, `
auth:
mode: "builtin"
builtin:
admin:
username: "admin"
token:
secret: "secret"
`)
// TTL defaults to 24 hours when not specified
assert.Equal(t, 24*time.Hour, cfg.Server.Auth.Builtin.Token.TTL)
})
}

View File

@ -37,7 +37,7 @@ func TestQueueShellConfig(t *testing.T) {
}
// Start the frontend server
server := frontend.NewServer(
server, err := frontend.NewServer(
th.Config,
th.DAGStore,
th.DAGRunStore,
@ -49,6 +49,7 @@ func TestQueueShellConfig(t *testing.T) {
nil, // no metrics registry
nil, // no resource service
)
require.NoError(t, err, "failed to create server")
go func() {
_ = server.Serve(th.Context)

View File

@ -0,0 +1,360 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
// Package fileuser provides a file-based implementation of the UserStore interface.
package fileuser
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/dagu-org/dagu/internal/auth"
)
const (
// userFileExtension is the file extension for user files.
userFileExtension = ".json"
// userDirPermissions is the permission mode for the users directory.
userDirPermissions = 0750
// userFilePermissions is the permission mode for user files.
userFilePermissions = 0600
)
// Store implements auth.UserStore using the local filesystem.
// Users are stored as individual JSON files in the configured directory.
// Thread-safe through internal locking.
type Store struct {
baseDir string
// mu protects the index maps
mu sync.RWMutex
// byID maps user ID to file path
byID map[string]string
// byUsername maps username to user ID
byUsername map[string]string
}
// Option is a functional option for configuring the Store.
type Option func(*Store)
// New creates a new file-based user store.
// New creates a file-backed Store that persists users as per-user JSON files in baseDir.
// The baseDir must be non-empty; provided Option functions are applied to the store.
// If baseDir does not exist it is created with directory permissions 0750, and an initial
// in-memory index is built from existing user files. Returns an error on invalid input,
// failure to create the directory, or failure to build the initial index.
func New(baseDir string, opts ...Option) (*Store, error) {
if baseDir == "" {
return nil, errors.New("fileuser: baseDir cannot be empty")
}
store := &Store{
baseDir: baseDir,
byID: make(map[string]string),
byUsername: make(map[string]string),
}
for _, opt := range opts {
opt(store)
}
// Create base directory if it doesn't exist
if err := os.MkdirAll(baseDir, userDirPermissions); err != nil {
return nil, fmt.Errorf("fileuser: failed to create directory %s: %w", baseDir, err)
}
// Build initial index
if err := store.rebuildIndex(); err != nil {
return nil, fmt.Errorf("fileuser: failed to build index: %w", err)
}
return store, nil
}
// rebuildIndex scans the directory and rebuilds the in-memory index.
func (s *Store) rebuildIndex() error {
s.mu.Lock()
defer s.mu.Unlock()
// Clear existing index
s.byID = make(map[string]string)
s.byUsername = make(map[string]string)
// Scan directory for user files
entries, err := os.ReadDir(s.baseDir)
if err != nil {
return fmt.Errorf("failed to read directory %s: %w", s.baseDir, err)
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != userFileExtension {
continue
}
filePath := filepath.Join(s.baseDir, entry.Name())
user, err := s.loadUserFromFile(filePath)
if err != nil {
// Log warning but continue - don't fail entire index for one bad file
slog.Warn("Failed to load user file during index rebuild",
slog.String("file", filePath),
slog.String("error", err.Error()))
continue
}
s.byID[user.ID] = filePath
s.byUsername[user.Username] = user.ID
}
return nil
}
// loadUserFromFile reads and parses a user from a JSON file.
func (s *Store) loadUserFromFile(filePath string) (*auth.User, error) {
data, err := os.ReadFile(filePath) //nolint:gosec // filePath is constructed internally from baseDir + userID
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
}
var stored auth.UserForStorage
if err := json.Unmarshal(data, &stored); err != nil {
return nil, fmt.Errorf("failed to parse user file %s: %w", filePath, err)
}
return stored.ToUser(), nil
}
// userFilePath returns the file path for a user ID.
func (s *Store) userFilePath(userID string) string {
return filepath.Join(s.baseDir, userID+userFileExtension)
}
// Create stores a new user.
func (s *Store) Create(_ context.Context, user *auth.User) error {
if user == nil {
return errors.New("fileuser: user cannot be nil")
}
if user.ID == "" {
return auth.ErrInvalidUserID
}
if user.Username == "" {
return auth.ErrInvalidUsername
}
s.mu.Lock()
defer s.mu.Unlock()
// Check if username already exists
if _, exists := s.byUsername[user.Username]; exists {
return auth.ErrUserAlreadyExists
}
// Check if ID already exists (shouldn't happen with UUIDs, but be safe)
if _, exists := s.byID[user.ID]; exists {
return auth.ErrUserAlreadyExists
}
// Write user to file
filePath := s.userFilePath(user.ID)
if err := s.writeUserToFile(filePath, user); err != nil {
return err
}
// Update index
s.byID[user.ID] = filePath
s.byUsername[user.Username] = user.ID
return nil
}
// writeUserToFile writes a user to a JSON file atomically.
func (s *Store) writeUserToFile(filePath string, user *auth.User) error {
data, err := json.MarshalIndent(user.ToStorage(), "", " ")
if err != nil {
return fmt.Errorf("fileuser: failed to marshal user: %w", err)
}
// Write to temp file first, then rename for atomicity
tempPath := filePath + ".tmp"
if err := os.WriteFile(tempPath, data, userFilePermissions); err != nil {
return fmt.Errorf("fileuser: failed to write file %s: %w", tempPath, err)
}
if err := os.Rename(tempPath, filePath); err != nil {
// Clean up temp file on failure
_ = os.Remove(tempPath)
return fmt.Errorf("fileuser: failed to rename file %s: %w", filePath, err)
}
return nil
}
// GetByID retrieves a user by their unique ID.
func (s *Store) GetByID(_ context.Context, id string) (*auth.User, error) {
if id == "" {
return nil, auth.ErrInvalidUserID
}
s.mu.RLock()
filePath, exists := s.byID[id]
s.mu.RUnlock()
if !exists {
return nil, auth.ErrUserNotFound
}
user, err := s.loadUserFromFile(filePath)
if err != nil {
// File might have been deleted externally
if errors.Is(err, os.ErrNotExist) {
return nil, auth.ErrUserNotFound
}
return nil, fmt.Errorf("fileuser: failed to load user %s: %w", id, err)
}
return user, nil
}
// GetByUsername retrieves a user by their username.
func (s *Store) GetByUsername(ctx context.Context, username string) (*auth.User, error) {
if username == "" {
return nil, auth.ErrInvalidUsername
}
s.mu.RLock()
userID, exists := s.byUsername[username]
s.mu.RUnlock()
if !exists {
return nil, auth.ErrUserNotFound
}
return s.GetByID(ctx, userID)
}
// List returns all users in the store.
func (s *Store) List(ctx context.Context) ([]*auth.User, error) {
s.mu.RLock()
ids := make([]string, 0, len(s.byID))
for id := range s.byID {
ids = append(ids, id)
}
s.mu.RUnlock()
users := make([]*auth.User, 0, len(ids))
for _, id := range ids {
user, err := s.GetByID(ctx, id)
if err != nil {
// Skip users that can't be loaded
if errors.Is(err, auth.ErrUserNotFound) {
continue
}
return nil, err
}
users = append(users, user)
}
return users, nil
}
// Update modifies an existing user.
func (s *Store) Update(_ context.Context, user *auth.User) error {
if user == nil {
return errors.New("fileuser: user cannot be nil")
}
if user.ID == "" {
return auth.ErrInvalidUserID
}
if user.Username == "" {
return auth.ErrInvalidUsername
}
s.mu.Lock()
defer s.mu.Unlock()
filePath, exists := s.byID[user.ID]
if !exists {
return auth.ErrUserNotFound
}
// Load existing user to check for username change
existingUser, err := s.loadUserFromFile(filePath)
if err != nil {
return fmt.Errorf("fileuser: failed to load existing user: %w", err)
}
// If username changed, check for conflicts and update index
if existingUser.Username != user.Username {
if existingID, taken := s.byUsername[user.Username]; taken && existingID != user.ID {
return auth.ErrUserAlreadyExists
}
delete(s.byUsername, existingUser.Username)
s.byUsername[user.Username] = user.ID
}
// Write updated user
if err := s.writeUserToFile(filePath, user); err != nil {
// Rollback index change on failure
if existingUser.Username != user.Username {
delete(s.byUsername, user.Username)
s.byUsername[existingUser.Username] = user.ID
}
return err
}
return nil
}
// Delete removes a user by their ID.
func (s *Store) Delete(_ context.Context, id string) error {
if id == "" {
return auth.ErrInvalidUserID
}
s.mu.Lock()
defer s.mu.Unlock()
filePath, exists := s.byID[id]
if !exists {
return auth.ErrUserNotFound
}
// Load user to get username for index cleanup
user, err := s.loadUserFromFile(filePath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("fileuser: failed to load user for deletion: %w", err)
}
// Remove file
if err := os.Remove(filePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("fileuser: failed to delete user file: %w", err)
}
// Update index
delete(s.byID, id)
if user != nil {
delete(s.byUsername, user.Username)
} else {
// File was already gone; remove any username entry that still points to this ID.
for username, userID := range s.byUsername {
if userID == id {
delete(s.byUsername, username)
break
}
}
}
return nil
}
// Count returns the total number of users.
func (s *Store) Count(_ context.Context) (int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return int64(len(s.byID)), nil
}

View File

@ -0,0 +1,240 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package fileuser
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/dagu-org/dagu/internal/auth"
)
func TestStore_CRUD(t *testing.T) {
// Create temp directory
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
// Create store
store, err := New(tmpDir)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
ctx := context.Background()
// Test Create
user := auth.NewUser("testuser", "hashedpassword", auth.RoleManager)
if err := store.Create(ctx, user); err != nil {
t.Fatalf("Create() error = %v", err)
}
// Test GetByID
retrieved, err := store.GetByID(ctx, user.ID)
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if retrieved.Username != user.Username {
t.Errorf("GetByID() username = %v, want %v", retrieved.Username, user.Username)
}
if retrieved.Role != user.Role {
t.Errorf("GetByID() role = %v, want %v", retrieved.Role, user.Role)
}
// Test GetByUsername
retrieved, err = store.GetByUsername(ctx, user.Username)
if err != nil {
t.Fatalf("GetByUsername() error = %v", err)
}
if retrieved.ID != user.ID {
t.Errorf("GetByUsername() ID = %v, want %v", retrieved.ID, user.ID)
}
// Test List
users, err := store.List(ctx)
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(users) != 1 {
t.Errorf("List() returned %d users, want 1", len(users))
}
// Test Count
count, err := store.Count(ctx)
if err != nil {
t.Fatalf("Count() error = %v", err)
}
if count != 1 {
t.Errorf("Count() = %d, want 1", count)
}
// Test Update
user.Role = auth.RoleAdmin
if err := store.Update(ctx, user); err != nil {
t.Fatalf("Update() error = %v", err)
}
retrieved, err = store.GetByID(ctx, user.ID)
if err != nil {
t.Fatalf("GetByID() after Update error = %v", err)
}
if retrieved.Role != auth.RoleAdmin {
t.Errorf("Update() role = %v, want %v", retrieved.Role, auth.RoleAdmin)
}
// Test Delete
if err := store.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete() error = %v", err)
}
_, err = store.GetByID(ctx, user.ID)
if err != auth.ErrUserNotFound {
t.Errorf("GetByID() after delete error = %v, want %v", err, auth.ErrUserNotFound)
}
}
func TestStore_DuplicateUsername(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
store, err := New(tmpDir)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
ctx := context.Background()
// Create first user
user1 := auth.NewUser("testuser", "hash1", auth.RoleViewer)
if err := store.Create(ctx, user1); err != nil {
t.Fatalf("Create() first user error = %v", err)
}
// Try to create second user with same username
user2 := auth.NewUser("testuser", "hash2", auth.RoleManager)
err = store.Create(ctx, user2)
if err != auth.ErrUserAlreadyExists {
t.Errorf("Create() duplicate username error = %v, want %v", err, auth.ErrUserAlreadyExists)
}
}
func TestStore_NotFound(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
store, err := New(tmpDir)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
ctx := context.Background()
// Test GetByID not found
_, err = store.GetByID(ctx, "nonexistent-id")
if err != auth.ErrUserNotFound {
t.Errorf("GetByID() error = %v, want %v", err, auth.ErrUserNotFound)
}
// Test GetByUsername not found
_, err = store.GetByUsername(ctx, "nonexistent-user")
if err != auth.ErrUserNotFound {
t.Errorf("GetByUsername() error = %v, want %v", err, auth.ErrUserNotFound)
}
// Test Delete not found
err = store.Delete(ctx, "nonexistent-id")
if err != auth.ErrUserNotFound {
t.Errorf("Delete() error = %v, want %v", err, auth.ErrUserNotFound)
}
// Test Update not found
user := auth.NewUser("test", "hash", auth.RoleViewer)
err = store.Update(ctx, user)
if err != auth.ErrUserNotFound {
t.Errorf("Update() error = %v, want %v", err, auth.ErrUserNotFound)
}
}
func TestStore_RebuildIndex(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
// Create store and add user
store1, err := New(tmpDir)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
ctx := context.Background()
user := auth.NewUser("testuser", "hash", auth.RoleAdmin)
if err := store1.Create(ctx, user); err != nil {
t.Fatalf("Create() error = %v", err)
}
// Create new store instance (simulates restart)
store2, err := New(tmpDir)
if err != nil {
t.Fatalf("failed to create second store: %v", err)
}
// Verify user is found after index rebuild
retrieved, err := store2.GetByUsername(ctx, "testuser")
if err != nil {
t.Fatalf("GetByUsername() after rebuild error = %v", err)
}
if retrieved.ID != user.ID {
t.Errorf("GetByUsername() after rebuild ID = %v, want %v", retrieved.ID, user.ID)
}
}
func TestStore_EmptyBaseDir(t *testing.T) {
_, err := New("")
if err == nil {
t.Error("New() with empty baseDir should return error")
}
}
func TestStore_FileExists(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
store, err := New(tmpDir)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
ctx := context.Background()
user := auth.NewUser("testuser", "hash", auth.RoleViewer)
if err := store.Create(ctx, user); err != nil {
t.Fatalf("Create() error = %v", err)
}
// Verify file exists
filePath := filepath.Join(tmpDir, user.ID+".json")
if _, err := os.Stat(filePath); os.IsNotExist(err) {
t.Error("User file should exist after Create()")
}
// Verify file is deleted after Delete
if err := store.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Error("User file should not exist after Delete()")
}
}

View File

@ -0,0 +1,393 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
// Package auth provides authentication and user management services.
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/dagu-org/dagu/internal/auth"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// Service errors.
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrInvalidToken = errors.New("invalid or expired token")
ErrTokenExpired = errors.New("token has expired")
ErrMissingSecret = errors.New("token secret is not configured")
ErrPasswordMismatch = errors.New("current password is incorrect")
ErrWeakPassword = errors.New("password does not meet requirements")
ErrCannotDeleteSelf = errors.New("cannot delete your own account")
)
const (
// defaultBcryptCost is the default cost for bcrypt hashing.
defaultBcryptCost = 12
// minPasswordLength is the minimum required password length.
minPasswordLength = 8
// defaultTokenTTL is the default token time-to-live.
defaultTokenTTL = 24 * time.Hour
)
// Config holds the configuration for the auth service.
type Config struct {
// TokenSecret is the secret key for signing JWT tokens.
TokenSecret string
// TokenTTL is the token time-to-live.
TokenTTL time.Duration
// BcryptCost is the cost factor for bcrypt hashing.
BcryptCost int
}
// Claims represents the JWT claims.
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"uid"`
Username string `json:"username"`
Role auth.Role `json:"role"`
}
// Service provides authentication and user management functionality.
type Service struct {
store auth.UserStore
config Config
}
// New creates a new auth service using the provided user store and configuration.
// If TokenTTL or BcryptCost are not set (<= 0) they are replaced with package defaults.
func New(store auth.UserStore, config Config) *Service {
if config.TokenTTL <= 0 {
config.TokenTTL = defaultTokenTTL
}
if config.BcryptCost <= 0 {
config.BcryptCost = defaultBcryptCost
}
return &Service{
store: store,
config: config,
}
}
// dummyHash is a valid bcrypt hash used for timing attack prevention.
// When a user is not found, we still perform a bcrypt comparison against this
// hash to ensure consistent response times regardless of user existence.
var dummyHash = []byte("$2a$12$K8gHXqrFdFvMwJBG0VlJGuAGz3FwBmTm8xnNQblN2tCxrQgPLmwHa")
// Authenticate verifies credentials and returns the user if valid.
func (s *Service) Authenticate(ctx context.Context, username, password string) (*auth.User, error) {
user, err := s.store.GetByUsername(ctx, username)
if err != nil {
if errors.Is(err, auth.ErrUserNotFound) || errors.Is(err, auth.ErrInvalidUsername) {
// Use constant-time comparison to prevent timing attacks.
// Compare against a valid bcrypt hash to ensure similar timing.
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return nil, ErrInvalidCredentials
}
return nil, fmt.Errorf("failed to get user: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return user, nil
}
// TokenResult contains the generated token and its expiry time.
type TokenResult struct {
Token string
ExpiresAt time.Time
}
// GenerateToken creates a JWT token for the given user.
// Returns the token string and its expiry time.
func (s *Service) GenerateToken(user *auth.User) (*TokenResult, error) {
if s.config.TokenSecret == "" {
return nil, ErrMissingSecret
}
now := time.Now()
expiresAt := now.Add(s.config.TokenTTL)
claims := &Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
UserID: user.ID,
Username: user.Username,
Role: user.Role,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := token.SignedString([]byte(s.config.TokenSecret))
if err != nil {
return nil, err
}
return &TokenResult{
Token: signedToken,
ExpiresAt: expiresAt,
}, nil
}
// ValidateToken validates a JWT token and returns the claims.
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
if s.config.TokenSecret == "" {
return nil, ErrMissingSecret
}
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.config.TokenSecret), nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
// GetUserFromToken validates a token and returns the associated user.
func (s *Service) GetUserFromToken(ctx context.Context, tokenString string) (*auth.User, error) {
claims, err := s.ValidateToken(tokenString)
if err != nil {
return nil, err
}
user, err := s.store.GetByID(ctx, claims.UserID)
if err != nil {
// If user was deleted after token was issued, treat as invalid token
if errors.Is(err, auth.ErrUserNotFound) || errors.Is(err, auth.ErrInvalidUserID) {
return nil, ErrInvalidToken
}
return nil, fmt.Errorf("failed to get user from token: %w", err)
}
return user, nil
}
// CreateUserInput contains the input for creating a user.
type CreateUserInput struct {
Username string
Password string
Role auth.Role
}
// CreateUser creates a new user.
func (s *Service) CreateUser(ctx context.Context, input CreateUserInput) (*auth.User, error) {
if err := s.validatePassword(input.Password); err != nil {
return nil, err
}
if !input.Role.Valid() {
return nil, fmt.Errorf("invalid role: %s", input.Role)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), s.config.BcryptCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
user := auth.NewUser(input.Username, string(passwordHash), input.Role)
if err := s.store.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// GetUser retrieves a user by ID.
func (s *Service) GetUser(ctx context.Context, id string) (*auth.User, error) {
return s.store.GetByID(ctx, id)
}
// ListUsers returns all users.
func (s *Service) ListUsers(ctx context.Context) ([]*auth.User, error) {
return s.store.List(ctx)
}
// UpdateUserInput contains the input for updating a user.
// Note: Password field is supported by the service for direct usage,
// but the API handler intentionally omits it - password changes should
// go through ChangePassword (user self-service) or ResetPassword (admin).
type UpdateUserInput struct {
Username *string
Role *auth.Role
Password *string
}
// UpdateUser updates an existing user.
func (s *Service) UpdateUser(ctx context.Context, id string, input UpdateUserInput) (*auth.User, error) {
user, err := s.store.GetByID(ctx, id)
if err != nil {
return nil, err
}
if input.Username != nil && *input.Username != "" {
user.Username = *input.Username
}
if input.Role != nil {
if !input.Role.Valid() {
return nil, fmt.Errorf("invalid role: %s", *input.Role)
}
user.Role = *input.Role
}
if input.Password != nil && *input.Password != "" {
if err := s.validatePassword(*input.Password); err != nil {
return nil, err
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*input.Password), s.config.BcryptCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
user.PasswordHash = string(passwordHash)
}
user.UpdatedAt = time.Now().UTC()
if err := s.store.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// DeleteUser deletes a user by ID.
// The currentUserID prevents users from deleting themselves.
func (s *Service) DeleteUser(ctx context.Context, id string, currentUserID string) error {
if id == currentUserID {
return ErrCannotDeleteSelf
}
return s.store.Delete(ctx, id)
}
// ChangePassword changes a user's password after verifying the old password.
func (s *Service) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
user, err := s.store.GetByID(ctx, userID)
if err != nil {
return err
}
// Verify old password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
return ErrPasswordMismatch
}
// Validate new password
if err := s.validatePassword(newPassword); err != nil {
return err
}
// Hash new password
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.config.BcryptCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user.PasswordHash = string(passwordHash)
user.UpdatedAt = time.Now().UTC()
return s.store.Update(ctx, user)
}
// ResetPassword allows an admin to reset a user's password without knowing the old password.
func (s *Service) ResetPassword(ctx context.Context, userID, newPassword string) error {
user, err := s.store.GetByID(ctx, userID)
if err != nil {
return err
}
// Validate new password
if err := s.validatePassword(newPassword); err != nil {
return err
}
// Hash new password
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.config.BcryptCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user.PasswordHash = string(passwordHash)
user.UpdatedAt = time.Now().UTC()
return s.store.Update(ctx, user)
}
// EnsureAdminUser creates the admin user if no users exist.
// Returns the generated password if a new admin was created.
func (s *Service) EnsureAdminUser(ctx context.Context, username, password string) (string, bool, error) {
count, err := s.store.Count(ctx)
if err != nil {
return "", false, fmt.Errorf("failed to count users: %w", err)
}
if count > 0 {
return "", false, nil
}
// Generate password if not provided
generatedPassword := password
if generatedPassword == "" {
generatedPassword, err = generateSecurePassword(16)
if err != nil {
return "", false, fmt.Errorf("failed to generate password: %w", err)
}
}
_, err = s.CreateUser(ctx, CreateUserInput{
Username: username,
Password: generatedPassword,
Role: auth.RoleAdmin,
})
if err != nil {
// Handle race condition: another process may have created the admin user
if errors.Is(err, auth.ErrUserAlreadyExists) {
return "", false, nil
}
return "", false, fmt.Errorf("failed to create admin user: %w", err)
}
return generatedPassword, true, nil
}
// validatePassword checks if a password meets the minimum requirements.
func (s *Service) validatePassword(password string) error {
if len(password) < minPasswordLength {
return fmt.Errorf("%w: minimum length is %d characters", ErrWeakPassword, minPasswordLength)
}
return nil
}
// generateSecurePassword returns a URL-safe base64-encoded string of the requested length
// built from cryptographically secure random bytes. It returns an error if a secure random
// source cannot be read.
func generateSecurePassword(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
}

View File

@ -0,0 +1,512 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/dagu-org/dagu/internal/auth"
"github.com/dagu-org/dagu/internal/persistence/fileuser"
)
func setupTestService(t *testing.T) (*Service, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "auth-service-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
store, err := fileuser.New(tmpDir)
if err != nil {
_ = os.RemoveAll(tmpDir)
t.Fatalf("failed to create store: %v", err)
}
config := Config{
TokenSecret: "test-secret-key-for-jwt-signing",
TokenTTL: time.Hour,
BcryptCost: 4, // Low cost for faster tests
}
svc := New(store, config)
cleanup := func() {
_ = os.RemoveAll(tmpDir)
}
return svc, cleanup
}
func TestService_CreateUser(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleManager,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
if user.Username != "testuser" {
t.Errorf("CreateUser() username = %v, want %v", user.Username, "testuser")
}
if user.Role != auth.RoleManager {
t.Errorf("CreateUser() role = %v, want %v", user.Role, auth.RoleManager)
}
if user.PasswordHash == "" {
t.Error("CreateUser() password hash should not be empty")
}
if user.PasswordHash == "password123" {
t.Error("CreateUser() password should be hashed")
}
}
func TestService_CreateUser_WeakPassword(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
_, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "short", // Too short
Role: auth.RoleViewer,
})
if err == nil {
t.Error("CreateUser() with weak password should return error")
}
}
func TestService_Authenticate(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
_, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleAdmin,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Test successful authentication
user, err := svc.Authenticate(ctx, "testuser", "password123")
if err != nil {
t.Fatalf("Authenticate() error = %v", err)
}
if user.Username != "testuser" {
t.Errorf("Authenticate() username = %v, want %v", user.Username, "testuser")
}
// Test wrong password
_, err = svc.Authenticate(ctx, "testuser", "wrongpassword")
if err != ErrInvalidCredentials {
t.Errorf("Authenticate() with wrong password error = %v, want %v", err, ErrInvalidCredentials)
}
// Test non-existent user
_, err = svc.Authenticate(ctx, "nonexistent", "password123")
if err != ErrInvalidCredentials {
t.Errorf("Authenticate() with non-existent user error = %v, want %v", err, ErrInvalidCredentials)
}
}
func TestService_GenerateAndValidateToken(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleManager,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Generate token
tokenResult, err := svc.GenerateToken(user)
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
if tokenResult.Token == "" {
t.Error("GenerateToken() returned empty token")
}
if tokenResult.ExpiresAt.IsZero() {
t.Error("GenerateToken() returned zero expiry time")
}
// Validate token
claims, err := svc.ValidateToken(tokenResult.Token)
if err != nil {
t.Fatalf("ValidateToken() error = %v", err)
}
if claims.UserID != user.ID {
t.Errorf("ValidateToken() userID = %v, want %v", claims.UserID, user.ID)
}
if claims.Username != user.Username {
t.Errorf("ValidateToken() username = %v, want %v", claims.Username, user.Username)
}
if claims.Role != user.Role {
t.Errorf("ValidateToken() role = %v, want %v", claims.Role, user.Role)
}
}
func TestService_ValidateToken_Invalid(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
// Test invalid token
_, err := svc.ValidateToken("invalid-token")
if err != ErrInvalidToken {
t.Errorf("ValidateToken() with invalid token error = %v, want %v", err, ErrInvalidToken)
}
}
func TestService_GetUserFromToken(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleViewer,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Generate token
tokenResult, err := svc.GenerateToken(user)
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
// Get user from token
retrieved, err := svc.GetUserFromToken(ctx, tokenResult.Token)
if err != nil {
t.Fatalf("GetUserFromToken() error = %v", err)
}
if retrieved.ID != user.ID {
t.Errorf("GetUserFromToken() ID = %v, want %v", retrieved.ID, user.ID)
}
}
func TestService_ChangePassword(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "oldpassword1",
Role: auth.RoleManager,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Change password
err = svc.ChangePassword(ctx, user.ID, "oldpassword1", "newpassword1")
if err != nil {
t.Fatalf("ChangePassword() error = %v", err)
}
// Verify old password no longer works
_, err = svc.Authenticate(ctx, "testuser", "oldpassword1")
if err != ErrInvalidCredentials {
t.Errorf("Authenticate() with old password should fail")
}
// Verify new password works
_, err = svc.Authenticate(ctx, "testuser", "newpassword1")
if err != nil {
t.Errorf("Authenticate() with new password error = %v", err)
}
}
func TestService_ChangePassword_WrongOldPassword(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleViewer,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Try to change with wrong old password
err = svc.ChangePassword(ctx, user.ID, "wrongpassword", "newpassword1")
if err != ErrPasswordMismatch {
t.Errorf("ChangePassword() with wrong old password error = %v, want %v", err, ErrPasswordMismatch)
}
}
func TestService_EnsureAdminUser(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// First call should create admin
password, created, err := svc.EnsureAdminUser(ctx, "admin", "adminpass1")
if err != nil {
t.Fatalf("EnsureAdminUser() error = %v", err)
}
if !created {
t.Error("EnsureAdminUser() should return created=true")
}
if password != "adminpass1" {
t.Errorf("EnsureAdminUser() password = %v, want %v", password, "adminpass1")
}
// Verify admin can authenticate
_, err = svc.Authenticate(ctx, "admin", "adminpass1")
if err != nil {
t.Errorf("Authenticate() admin error = %v", err)
}
// Second call should not create
_, created, err = svc.EnsureAdminUser(ctx, "admin2", "adminpass2")
if err != nil {
t.Fatalf("EnsureAdminUser() second call error = %v", err)
}
if created {
t.Error("EnsureAdminUser() should return created=false when users exist")
}
}
func TestService_EnsureAdminUser_GeneratePassword(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Call with empty password should generate one
password, created, err := svc.EnsureAdminUser(ctx, "admin", "")
if err != nil {
t.Fatalf("EnsureAdminUser() error = %v", err)
}
if !created {
t.Error("EnsureAdminUser() should return created=true")
}
if password == "" {
t.Error("EnsureAdminUser() should generate a password")
}
if len(password) < 8 {
t.Error("Generated password should be at least 8 characters")
}
// Verify admin can authenticate with generated password
_, err = svc.Authenticate(ctx, "admin", password)
if err != nil {
t.Errorf("Authenticate() admin with generated password error = %v", err)
}
}
func TestService_DeleteUser(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleManager,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Delete user
err = svc.DeleteUser(ctx, user.ID, "other-user-id")
if err != nil {
t.Fatalf("DeleteUser() error = %v", err)
}
// Verify user is deleted
_, err = svc.GetUser(ctx, user.ID)
if err != auth.ErrUserNotFound {
t.Errorf("GetUser() after delete error = %v, want %v", err, auth.ErrUserNotFound)
}
}
func TestService_DeleteUser_CannotDeleteSelf(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleAdmin,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Try to delete self
err = svc.DeleteUser(ctx, user.ID, user.ID)
if err != ErrCannotDeleteSelf {
t.Errorf("DeleteUser() self error = %v, want %v", err, ErrCannotDeleteSelf)
}
}
func TestService_UpdateUser(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleViewer,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Update role
newRole := auth.RoleAdmin
updated, err := svc.UpdateUser(ctx, user.ID, UpdateUserInput{
Role: &newRole,
})
if err != nil {
t.Fatalf("UpdateUser() error = %v", err)
}
if updated.Role != auth.RoleAdmin {
t.Errorf("UpdateUser() role = %v, want %v", updated.Role, auth.RoleAdmin)
}
// Update username
newUsername := "newusername"
updated, err = svc.UpdateUser(ctx, user.ID, UpdateUserInput{
Username: &newUsername,
})
if err != nil {
t.Fatalf("UpdateUser() error = %v", err)
}
if updated.Username != "newusername" {
t.Errorf("UpdateUser() username = %v, want %v", updated.Username, "newusername")
}
}
func TestService_ListUsers(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create multiple users
for i := 0; i < 3; i++ {
_, err := svc.CreateUser(ctx, CreateUserInput{
Username: fmt.Sprintf("user%d", i),
Password: "password123",
Role: auth.RoleViewer,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
}
// List users
users, err := svc.ListUsers(ctx)
if err != nil {
t.Fatalf("ListUsers() error = %v", err)
}
if len(users) != 3 {
t.Errorf("ListUsers() returned %d users, want 3", len(users))
}
}
func TestService_ResetPassword(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "oldpassword1",
Role: auth.RoleManager,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Reset password (admin action, doesn't require old password)
err = svc.ResetPassword(ctx, user.ID, "newpassword1")
if err != nil {
t.Fatalf("ResetPassword() error = %v", err)
}
// Verify old password no longer works
_, err = svc.Authenticate(ctx, "testuser", "oldpassword1")
if err != ErrInvalidCredentials {
t.Errorf("Authenticate() with old password should fail")
}
// Verify new password works
_, err = svc.Authenticate(ctx, "testuser", "newpassword1")
if err != nil {
t.Errorf("Authenticate() with new password error = %v", err)
}
}
func TestService_ResetPassword_WeakPassword(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()
ctx := context.Background()
// Create user
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleViewer,
})
if err != nil {
t.Fatalf("CreateUser() error = %v", err)
}
// Try to reset with weak password
err = svc.ResetPassword(ctx, user.ID, "weak")
if err == nil {
t.Error("ResetPassword() with weak password should return error")
}
}

View File

@ -1,29 +0,0 @@
package api_test
import (
"net/http"
"testing"
"github.com/dagu-org/dagu/api/v1"
"github.com/dagu-org/dagu/internal/test"
"github.com/stretchr/testify/require"
)
func TestDAG(t *testing.T) {
server := test.SetupServer(t)
// Create a new DAG
_ = server.Client().Post("/api/v1/dags", api.CreateDAGJSONRequestBody{
Value: "test_dag",
}).ExpectStatus(http.StatusCreated).Send(t)
// Fetch the created DAG with the list endpoint
resp := server.Client().Get("/api/v1/dags?name=test_dag").ExpectStatus(http.StatusOK).Send(t)
var apiResp api.ListDAGs200JSONResponse
resp.Unmarshal(t, &apiResp)
require.Len(t, apiResp.DAGs, 1, "expected one DAG")
// Delete the created DAG
_ = server.Client().Delete("/api/v1/dags/test_dag").ExpectStatus(http.StatusNoContent).Send(t)
}

View File

@ -1,20 +0,0 @@
package api_test
import (
"net/http"
"testing"
"github.com/dagu-org/dagu/api/v1"
"github.com/dagu-org/dagu/internal/test"
"github.com/stretchr/testify/require"
)
func TestHealthCheck(t *testing.T) {
server := test.SetupServer(t)
resp := server.Client().Get("/api/v1/health").ExpectStatus(http.StatusOK).Send(t)
var healthResp api.HealthResponse
resp.Unmarshal(t, &healthResp)
require.Equal(t, api.HealthResponseStatusHealthy, healthResp.Status, "expected status 'ok'")
}

View File

@ -11,15 +11,17 @@ import (
"strings"
"github.com/dagu-org/dagu/api/v2"
"github.com/dagu-org/dagu/internal/auth"
"github.com/dagu-org/dagu/internal/common/cmdutil"
"github.com/dagu-org/dagu/internal/common/config"
"github.com/dagu-org/dagu/internal/common/logger"
"github.com/dagu-org/dagu/internal/common/logger/tag"
"github.com/dagu-org/dagu/internal/core/execution"
"github.com/dagu-org/dagu/internal/runtime"
authservice "github.com/dagu-org/dagu/internal/service/auth"
"github.com/dagu-org/dagu/internal/service/coordinator"
"github.com/dagu-org/dagu/internal/service/frontend/api/pathutil"
"github.com/dagu-org/dagu/internal/service/frontend/auth"
frontendauth "github.com/dagu-org/dagu/internal/service/frontend/auth"
"github.com/dagu-org/dagu/internal/service/resource"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
@ -45,12 +47,45 @@ type API struct {
serviceRegistry execution.ServiceRegistry
subCmdBuilder *runtime.SubCmdBuilder
resourceService *resource.Service
authService AuthService
}
// AuthService defines the interface for authentication operations.
// This allows the API to work with or without auth service being configured.
type AuthService interface {
Authenticate(ctx context.Context, username, password string) (*auth.User, error)
GenerateToken(user *auth.User) (*authservice.TokenResult, error)
GetUserFromToken(ctx context.Context, token string) (*auth.User, error)
CreateUser(ctx context.Context, input authservice.CreateUserInput) (*auth.User, error)
GetUser(ctx context.Context, id string) (*auth.User, error)
ListUsers(ctx context.Context) ([]*auth.User, error)
UpdateUser(ctx context.Context, id string, input authservice.UpdateUserInput) (*auth.User, error)
DeleteUser(ctx context.Context, id string, currentUserID string) error
ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error
ResetPassword(ctx context.Context, userID, newPassword string) error
}
// APIOption is a functional option for configuring the API.
type APIOption func(*API)
// WithAuthService returns an APIOption that sets the API's AuthService.
// When applied, the provided AuthService will be used by API methods and middleware; passing nil disables authentication.
func WithAuthService(as AuthService) APIOption {
return func(a *API) {
a.authService = as
}
}
// New constructs an API instance wired with the provided DAG, DAG-run, queue and proc stores,
// runtime manager, configuration, coordinator client, service registry, metrics registry, and resource service.
// It also builds the internal remote node map from cfg.Server.RemoteNodes and initializes the sub-command
// builder and API base path from the provided configuration.
// New constructs an *API configured with the provided stores, runtime manager,
// configuration, coordinator client, service registry, Prometheus registry,
// and resource service.
//
// It builds the API instance (including the remote node map and base path) and
// applies any supplied APIOption functions to customize the instance before
// returning it.
func New(
dr execution.DAGStore,
drs execution.DAGRunStore,
@ -62,13 +97,14 @@ func New(
sr execution.ServiceRegistry,
mr *prometheus.Registry,
rs *resource.Service,
opts ...APIOption,
) *API {
remoteNodes := make(map[string]config.RemoteNode)
for _, n := range cfg.Server.RemoteNodes {
remoteNodes[n.Name] = n
}
return &API{
a := &API{
dagStore: dr,
dagRunStore: drs,
queueStore: qs,
@ -84,6 +120,12 @@ func New(
metricsRegistry: mr,
resourceService: rs,
}
for _, opt := range opts {
opt(a)
}
return a
}
func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string) error {
@ -150,7 +192,7 @@ func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string)
} else {
authConfig = evaluatedAuth
}
authOptions := auth.Options{
authOptions := frontendauth.Options{
Realm: "restricted",
APITokenEnabled: authConfig.Token.Value != "",
APIToken: authConfig.Token.Value,
@ -159,13 +201,14 @@ func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string)
PublicPaths: []string{
pathutil.BuildPublicEndpointPath(basePath, "api/v2/health"),
pathutil.BuildPublicEndpointPath(basePath, "api/v2/metrics"),
pathutil.BuildPublicEndpointPath(basePath, "api/v2/auth/login"),
},
}
// Initialize OIDC if enabled
authOIDC := authConfig.OIDC
if authOIDC.ClientId != "" && authOIDC.ClientSecret != "" && authOIDC.Issuer != "" {
oidcCfg, err := auth.InitVerifierAndConfig(authOIDC)
oidcCfg, err := frontendauth.InitVerifierAndConfig(authOIDC)
if err != nil {
return fmt.Errorf("failed to initialize OIDC: %w", err)
}
@ -176,13 +219,30 @@ func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string)
authOptions.OIDCConfig = oidcCfg.Config
}
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(authOptions))
r.Use(WithRemoteNode(a.remoteNodes, a.apiBasePath))
// Apply authentication middleware based on auth mode
if authConfig.Mode == config.AuthModeBuiltin {
if a.authService == nil {
return fmt.Errorf("builtin auth mode configured but auth service not initialized")
}
r.Group(func(r chi.Router) {
// For builtin auth, use JWT-based authentication
// The BuiltinAuthMiddleware validates JWT tokens and injects user into context
r.Use(frontendauth.BuiltinAuthMiddleware(a.authService, authOptions.PublicPaths))
r.Use(WithRemoteNode(a.remoteNodes, a.apiBasePath))
handler := api.NewStrictHandlerWithOptions(a, nil, options)
r.Mount("/", api.Handler(handler))
})
handler := api.NewStrictHandlerWithOptions(a, nil, options)
r.Mount("/", api.Handler(handler))
})
} else {
r.Group(func(r chi.Router) {
// For other auth modes (basic, token, OIDC), use the legacy middleware
r.Use(frontendauth.Middleware(authOptions))
r.Use(WithRemoteNode(a.remoteNodes, a.apiBasePath))
handler := api.NewStrictHandlerWithOptions(a, nil, options)
r.Mount("/", api.Handler(handler))
})
}
return nil
}
@ -243,6 +303,91 @@ func (a *API) isAllowed(perm config.Permission) error {
return nil
}
// requireAdmin checks if the current user has admin role.
// Returns nil if auth is not enabled (authService is nil).
func (a *API) requireAdmin(ctx context.Context) error {
if a.authService == nil {
return nil // Auth not enabled, allow access
}
user, ok := auth.UserFromContext(ctx)
if !ok {
return &Error{
Code: api.ErrorCodeUnauthorized,
Message: "Authentication required",
HTTPStatus: http.StatusUnauthorized,
}
}
if !user.Role.IsAdmin() {
return &Error{
Code: api.ErrorCodeForbidden,
Message: "Insufficient permissions",
HTTPStatus: http.StatusForbidden,
}
}
return nil
}
// requireWrite checks if the current user can write (create/edit/delete) DAGs.
// Returns nil if auth is not enabled (authService is nil).
func (a *API) requireWrite(ctx context.Context) error {
if a.authService == nil {
return nil // Auth not enabled, allow access
}
user, ok := auth.UserFromContext(ctx)
if !ok {
return &Error{
Code: api.ErrorCodeUnauthorized,
Message: "Authentication required",
HTTPStatus: http.StatusUnauthorized,
}
}
if !user.Role.CanWrite() {
return &Error{
Code: api.ErrorCodeForbidden,
Message: "Insufficient permissions",
HTTPStatus: http.StatusForbidden,
}
}
return nil
}
// requireExecute checks if the current user can execute (run/stop) DAGs.
// Returns nil if auth is not enabled (authService is nil).
func (a *API) requireExecute(ctx context.Context) error {
if a.authService == nil {
return nil // Auth not enabled, allow access
}
user, ok := auth.UserFromContext(ctx)
if !ok {
return &Error{
Code: api.ErrorCodeUnauthorized,
Message: "Authentication required",
HTTPStatus: http.StatusUnauthorized,
}
}
if !user.Role.CanExecute() {
return &Error{
Code: api.ErrorCodeForbidden,
Message: "Insufficient permissions",
HTTPStatus: http.StatusForbidden,
}
}
return nil
}
// requireUserManagement checks if user management is enabled.
func (a *API) requireUserManagement() error {
if a.authService == nil {
return &Error{
Code: api.ErrorCodeUnauthorized,
Message: "User management is not enabled",
HTTPStatus: http.StatusUnauthorized,
}
}
return nil
}
// ptrOf returns a pointer to v, or nil if v is the zero value for its type.
func ptrOf[T any](v T) *T {
if reflect.ValueOf(v).IsZero() {
return nil

View File

@ -0,0 +1,134 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package api
import (
"context"
"errors"
"github.com/dagu-org/dagu/api/v2"
"github.com/dagu-org/dagu/internal/auth"
authservice "github.com/dagu-org/dagu/internal/service/auth"
)
// Login authenticates a user and returns a JWT token.
func (a *API) Login(ctx context.Context, request api.LoginRequestObject) (api.LoginResponseObject, error) {
if a.authService == nil {
return api.Login401JSONResponse{
Code: api.ErrorCodeUnauthorized,
Message: "Authentication is not enabled",
}, nil
}
if request.Body == nil {
return api.Login401JSONResponse{
Code: api.ErrorCodeUnauthorized,
Message: "Invalid request body",
}, nil
}
user, err := a.authService.Authenticate(ctx, request.Body.Username, request.Body.Password)
if err != nil {
if errors.Is(err, authservice.ErrInvalidCredentials) {
return api.Login401JSONResponse{
Code: api.ErrorCodeUnauthorized,
Message: "Invalid username or password",
}, nil
}
return nil, err
}
tokenResult, err := a.authService.GenerateToken(user)
if err != nil {
return nil, err
}
return api.Login200JSONResponse{
Token: tokenResult.Token,
ExpiresAt: tokenResult.ExpiresAt,
User: toAPIUser(user),
}, nil
}
// GetCurrentUser returns the currently authenticated user.
func (a *API) GetCurrentUser(ctx context.Context, _ api.GetCurrentUserRequestObject) (api.GetCurrentUserResponseObject, error) {
user, ok := auth.UserFromContext(ctx)
if !ok {
return api.GetCurrentUser401JSONResponse{
Code: api.ErrorCodeUnauthorized,
Message: "Not authenticated",
}, nil
}
return api.GetCurrentUser200JSONResponse{
User: toAPIUser(user),
}, nil
}
// ChangePassword allows the authenticated user to change their own password.
func (a *API) ChangePassword(ctx context.Context, request api.ChangePasswordRequestObject) (api.ChangePasswordResponseObject, error) {
if a.authService == nil {
return api.ChangePassword401JSONResponse{
Code: api.ErrorCodeUnauthorized,
Message: "Authentication is not enabled",
}, nil
}
user, ok := auth.UserFromContext(ctx)
if !ok {
return api.ChangePassword401JSONResponse{
Code: api.ErrorCodeUnauthorized,
Message: "Not authenticated",
}, nil
}
if request.Body == nil {
return api.ChangePassword400JSONResponse{
Code: api.ErrorCodeBadRequest,
Message: "Invalid request body",
}, nil
}
err := a.authService.ChangePassword(ctx, user.ID, request.Body.CurrentPassword, request.Body.NewPassword)
if err != nil {
if errors.Is(err, authservice.ErrPasswordMismatch) {
return api.ChangePassword401JSONResponse{
Code: api.ErrorCodeUnauthorized,
Message: "Current password is incorrect",
}, nil
}
if errors.Is(err, authservice.ErrWeakPassword) {
return api.ChangePassword400JSONResponse{
Code: api.ErrorCodeBadRequest,
Message: "New password does not meet security requirements",
}, nil
}
return nil, err
}
return api.ChangePassword200JSONResponse{
Message: "Password changed successfully",
}, nil
}
// toAPIUser converts a core auth.User into its API representation.
// The provided user must be non-nil.
func toAPIUser(user *auth.User) api.User {
return api.User{
Id: user.ID,
Username: user.Username,
Role: api.UserRole(user.Role),
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
// preserving the input order.
func toAPIUsers(users []*auth.User) []api.User {
result := make([]api.User, len(users))
for i, u := range users {
result[i] = toAPIUser(u)
}
return result
}

View File

@ -26,6 +26,9 @@ func (a *API) ExecuteDAGRunFromSpec(ctx context.Context, request api.ExecuteDAGR
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
if request.Body == nil || request.Body.Spec == "" {
return nil, &Error{
@ -83,6 +86,9 @@ func (a *API) EnqueueDAGRunFromSpec(ctx context.Context, request api.EnqueueDAGR
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
if request.Body == nil || request.Body.Spec == "" {
return nil, &Error{
@ -384,6 +390,9 @@ func (a *API) UpdateDAGRunStepStatus(ctx context.Context, request api.UpdateDAGR
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
ref := execution.NewDAGRunRef(request.Name, request.DagRunId)
dagStatus, err := a.dagRunMgr.GetSavedStatus(ctx, ref)
@ -563,6 +572,9 @@ func (a *API) UpdateSubDAGRunStepStatus(ctx context.Context, request api.UpdateS
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
root := execution.NewDAGRunRef(request.Name, request.DagRunId)
dagStatus, err := a.dagRunMgr.FindSubDAGRunStatus(ctx, root, request.SubDAGRunId)
@ -615,6 +627,9 @@ func (a *API) RetryDAGRun(ctx context.Context, request api.RetryDAGRunRequestObj
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
attempt, err := a.dagRunStore.FindAttempt(ctx, execution.NewDAGRunRef(request.Name, request.DagRunId))
if err != nil {
@ -671,6 +686,9 @@ func (a *API) TerminateDAGRun(ctx context.Context, request api.TerminateDAGRunRe
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
attempt, err := a.dagRunStore.FindAttempt(ctx, execution.NewDAGRunRef(request.Name, request.DagRunId))
if err != nil {
@ -714,6 +732,9 @@ func (a *API) DequeueDAGRun(ctx context.Context, request api.DequeueDAGRunReques
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
dagRun := execution.NewDAGRunRef(request.Name, request.DagRunId)
attempt, err := a.dagRunStore.FindAttempt(ctx, dagRun)
@ -755,6 +776,9 @@ func (a *API) RescheduleDAGRun(ctx context.Context, request api.RescheduleDAGRun
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
attempt, err := a.dagRunStore.FindAttempt(ctx, execution.NewDAGRunRef(request.Name, request.DagRunId))
if err != nil {

View File

@ -72,6 +72,9 @@ func (a *API) CreateNewDAG(ctx context.Context, request api.CreateNewDAGRequestO
if err := a.isAllowed(config.PermissionWriteDAGs); err != nil {
return nil, err
}
if err := a.requireWrite(ctx); err != nil {
return nil, err
}
// Determine spec to create with: provided spec or default template
var yamlSpec []byte
@ -126,6 +129,9 @@ func (a *API) DeleteDAG(ctx context.Context, request api.DeleteDAGRequestObject)
if err := a.isAllowed(config.PermissionWriteDAGs); err != nil {
return nil, err
}
if err := a.requireWrite(ctx); err != nil {
return nil, err
}
_, err := a.dagStore.GetMetadata(ctx, request.FileName)
if err != nil {
@ -190,6 +196,9 @@ func (a *API) UpdateDAGSpec(ctx context.Context, request api.UpdateDAGSpecReques
if err := a.isAllowed(config.PermissionWriteDAGs); err != nil {
return nil, err
}
if err := a.requireWrite(ctx); err != nil {
return nil, err
}
err := a.dagStore.UpdateSpec(ctx, request.FileName, []byte(request.Body.Spec))
@ -211,6 +220,9 @@ func (a *API) RenameDAG(ctx context.Context, request api.RenameDAGRequestObject)
if err := a.isAllowed(config.PermissionWriteDAGs); err != nil {
return nil, err
}
if err := a.requireWrite(ctx); err != nil {
return nil, err
}
dag, err := a.dagStore.GetMetadata(ctx, request.FileName)
if err != nil {
@ -523,6 +535,9 @@ func (a *API) ExecuteDAG(ctx context.Context, request api.ExecuteDAGRequestObjec
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
dag, err := a.dagStore.GetDetails(ctx, request.FileName)
if err != nil {
@ -734,6 +749,9 @@ func (a *API) EnqueueDAGDAGRun(ctx context.Context, request api.EnqueueDAGDAGRun
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
dag, err := a.dagStore.GetDetails(ctx, request.FileName, spec.WithoutEval())
if err != nil {
@ -863,6 +881,9 @@ func (a *API) UpdateDAGSuspensionState(ctx context.Context, request api.UpdateDA
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
_, err := a.dagStore.GetMetadata(ctx, request.FileName)
if err != nil {
@ -914,6 +935,9 @@ func (a *API) StopAllDAGRuns(ctx context.Context, request api.StopAllDAGRunsRequ
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
return nil, err
}
if err := a.requireExecute(ctx); err != nil {
return nil, err
}
// Get the DAG metadata to ensure it exists
dag, err := a.dagStore.GetMetadata(ctx, request.FileName)

View File

@ -0,0 +1,266 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package api
import (
"context"
"errors"
"net/http"
"github.com/dagu-org/dagu/api/v2"
"github.com/dagu-org/dagu/internal/auth"
authservice "github.com/dagu-org/dagu/internal/service/auth"
)
// ListUsers returns a list of all users. Requires admin role.
func (a *API) ListUsers(ctx context.Context, _ api.ListUsersRequestObject) (api.ListUsersResponseObject, error) {
if err := a.requireUserManagement(); err != nil {
return nil, err
}
if err := a.requireAdmin(ctx); err != nil {
return nil, err
}
users, err := a.authService.ListUsers(ctx)
if err != nil {
return nil, err
}
return api.ListUsers200JSONResponse{
Users: toAPIUsers(users),
}, nil
}
// CreateUser creates a new user. Requires admin role.
func (a *API) CreateUser(ctx context.Context, request api.CreateUserRequestObject) (api.CreateUserResponseObject, error) {
if err := a.requireUserManagement(); err != nil {
return nil, err
}
if err := a.requireAdmin(ctx); err != nil {
return nil, err
}
if request.Body == nil {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Invalid request body",
HTTPStatus: http.StatusBadRequest,
}
}
role, err := auth.ParseRole(string(request.Body.Role))
if err != nil {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Invalid role",
HTTPStatus: http.StatusBadRequest,
}
}
user, err := a.authService.CreateUser(ctx, authservice.CreateUserInput{
Username: request.Body.Username,
Password: request.Body.Password,
Role: role,
})
if err != nil {
if errors.Is(err, auth.ErrUserAlreadyExists) {
return nil, &Error{
Code: api.ErrorCodeAlreadyExists,
Message: "Username already exists",
HTTPStatus: http.StatusConflict,
}
}
if errors.Is(err, auth.ErrInvalidUsername) {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Invalid username",
HTTPStatus: http.StatusBadRequest,
}
}
if errors.Is(err, authservice.ErrWeakPassword) {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Password does not meet security requirements",
HTTPStatus: http.StatusBadRequest,
}
}
return nil, err
}
return api.CreateUser201JSONResponse{
User: toAPIUser(user),
}, nil
}
// GetUser returns a specific user by ID. Requires admin role.
func (a *API) GetUser(ctx context.Context, request api.GetUserRequestObject) (api.GetUserResponseObject, error) {
if err := a.requireUserManagement(); err != nil {
return nil, err
}
if err := a.requireAdmin(ctx); err != nil {
return nil, err
}
user, err := a.authService.GetUser(ctx, request.UserId)
if err != nil {
if errors.Is(err, auth.ErrUserNotFound) {
return nil, &Error{
Code: api.ErrorCodeNotFound,
Message: "User not found",
HTTPStatus: http.StatusNotFound,
}
}
return nil, err
}
return api.GetUser200JSONResponse{
User: toAPIUser(user),
}, nil
}
// UpdateUser updates a user's information. Requires admin role.
func (a *API) UpdateUser(ctx context.Context, request api.UpdateUserRequestObject) (api.UpdateUserResponseObject, error) {
if err := a.requireUserManagement(); err != nil {
return nil, err
}
if err := a.requireAdmin(ctx); err != nil {
return nil, err
}
if request.Body == nil {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Invalid request body",
HTTPStatus: http.StatusBadRequest,
}
}
input := authservice.UpdateUserInput{}
if request.Body.Username != nil {
input.Username = request.Body.Username
}
if request.Body.Role != nil {
role, err := auth.ParseRole(string(*request.Body.Role))
if err != nil {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Invalid role",
HTTPStatus: http.StatusBadRequest,
}
}
input.Role = &role
}
user, err := a.authService.UpdateUser(ctx, request.UserId, input)
if err != nil {
if errors.Is(err, auth.ErrUserNotFound) {
return nil, &Error{
Code: api.ErrorCodeNotFound,
Message: "User not found",
HTTPStatus: http.StatusNotFound,
}
}
if errors.Is(err, auth.ErrUserAlreadyExists) {
return nil, &Error{
Code: api.ErrorCodeAlreadyExists,
Message: "Username already exists",
HTTPStatus: http.StatusConflict,
}
}
if errors.Is(err, auth.ErrInvalidUsername) {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Invalid username",
HTTPStatus: http.StatusBadRequest,
}
}
return nil, err
}
return api.UpdateUser200JSONResponse{
User: toAPIUser(user),
}, nil
}
// DeleteUser deletes a user account. Requires admin role. Cannot delete yourself.
func (a *API) DeleteUser(ctx context.Context, request api.DeleteUserRequestObject) (api.DeleteUserResponseObject, error) {
if err := a.requireUserManagement(); err != nil {
return nil, err
}
if err := a.requireAdmin(ctx); err != nil {
return nil, err
}
// Get current user to prevent self-deletion
currentUser, ok := auth.UserFromContext(ctx)
if !ok {
return nil, &Error{
Code: api.ErrorCodeUnauthorized,
Message: "Not authenticated",
HTTPStatus: http.StatusUnauthorized,
}
}
err := a.authService.DeleteUser(ctx, request.UserId, currentUser.ID)
if err != nil {
if errors.Is(err, auth.ErrUserNotFound) {
return nil, &Error{
Code: api.ErrorCodeNotFound,
Message: "User not found",
HTTPStatus: http.StatusNotFound,
}
}
if errors.Is(err, authservice.ErrCannotDeleteSelf) {
return nil, &Error{
Code: api.ErrorCodeForbidden,
Message: "Cannot delete your own account",
HTTPStatus: http.StatusForbidden,
}
}
return nil, err
}
return api.DeleteUser204Response{}, nil
}
// ResetUserPassword resets a user's password. Requires admin role.
func (a *API) ResetUserPassword(ctx context.Context, request api.ResetUserPasswordRequestObject) (api.ResetUserPasswordResponseObject, error) {
if err := a.requireUserManagement(); err != nil {
return nil, err
}
if err := a.requireAdmin(ctx); err != nil {
return nil, err
}
if request.Body == nil {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Invalid request body",
HTTPStatus: http.StatusBadRequest,
}
}
err := a.authService.ResetPassword(ctx, request.UserId, request.Body.NewPassword)
if err != nil {
if errors.Is(err, auth.ErrUserNotFound) {
return nil, &Error{
Code: api.ErrorCodeNotFound,
Message: "User not found",
HTTPStatus: http.StatusNotFound,
}
}
if errors.Is(err, authservice.ErrWeakPassword) {
return nil, &Error{
Code: api.ErrorCodeBadRequest,
Message: "Password does not meet security requirements",
HTTPStatus: http.StatusBadRequest,
}
}
return nil, err
}
return api.ResetUserPassword200JSONResponse{
Message: "Password reset successfully",
}, nil
}

View File

@ -0,0 +1,162 @@
// Copyright (C) 2024 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/dagu-org/dagu/internal/auth"
)
// TokenValidator defines the interface for validating tokens and retrieving users.
// This allows the middleware to work with any token validation implementation.
type TokenValidator interface {
GetUserFromToken(ctx context.Context, token string) (*auth.User, error)
}
// BuiltinAuthMiddleware creates middleware that validates JWT tokens
// and injects the authenticated user into the request context.
// BuiltinAuthMiddleware returns an HTTP middleware that enforces authentication for requests
// whose paths are not listed in publicPaths. For non-public requests it extracts a Bearer token,
// validates it via the provided TokenValidator, and on success injects the authenticated user into
// the request context; on failure it writes a 401 JSON error response.
func BuiltinAuthMiddleware(svc TokenValidator, publicPaths []string) func(http.Handler) http.Handler {
// Build a set for O(1) lookup
publicSet := make(map[string]struct{}, len(publicPaths))
for _, p := range publicPaths {
publicSet[p] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if path is public
if isPublicPath(r.URL.Path, publicSet) {
next.ServeHTTP(w, r)
return
}
token := extractBearerToken(r)
if token == "" {
writeAuthError(w, http.StatusUnauthorized, "auth.unauthorized", "Authentication required")
return
}
user, err := svc.GetUserFromToken(r.Context(), token)
if err != nil {
writeAuthError(w, http.StatusUnauthorized, "auth.token_invalid", "Invalid or expired token")
return
}
// Inject user into context
ctx := auth.WithUser(r.Context(), user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// isPublicPath checks if the request path matches any public path.
// plus variants with a trailing slash added or removed.
func isPublicPath(path string, publicSet map[string]struct{}) bool {
// Exact match
if _, ok := publicSet[path]; ok {
return true
}
// Try with trailing slash removed
withoutSlash := strings.TrimSuffix(path, "/")
if _, ok := publicSet[withoutSlash]; ok {
return true
}
// Try with trailing slash added
if path != "" && !strings.HasSuffix(path, "/") {
if _, ok := publicSet[path+"/"]; ok {
return true
}
}
return false
}
// RequireRole creates middleware that checks if the authenticated user
// ("auth.forbidden").
func RequireRole(roles ...auth.Role) func(http.Handler) http.Handler {
roleSet := make(map[auth.Role]struct{}, len(roles))
for _, r := range roles {
roleSet[r] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeAuthError(w, http.StatusUnauthorized, "auth.unauthorized", "Authentication required")
return
}
if _, allowed := roleSet[user.Role]; !allowed {
writeAuthError(w, http.StatusForbidden, "auth.forbidden", "Insufficient permissions")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireAdmin returns a middleware that allows only authenticated users with the admin role.
func RequireAdmin() func(http.Handler) http.Handler {
return RequireRole(auth.RoleAdmin)
}
// RequireWrite returns middleware that permits requests only for users with the Admin or Manager role.
func RequireWrite() func(http.Handler) http.Handler {
return RequireRole(auth.RoleAdmin, auth.RoleManager)
}
// RequireExecute is middleware that permits requests only from users with the Admin, Manager, or Operator role.
func RequireExecute() func(http.Handler) http.Handler {
return RequireRole(auth.RoleAdmin, auth.RoleManager, auth.RoleOperator)
}
// extractBearerToken extracts the bearer token from the request's Authorization header.
// It returns the token string without the "Bearer " prefix, or an empty string if the header is missing or not a Bearer token.
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ""
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
return ""
}
return strings.TrimPrefix(authHeader, bearerPrefix)
}
// ErrorResponse represents an error response.
type ErrorResponse struct {
Error ErrorDetail `json:"error"`
}
// ErrorDetail contains error details.
type ErrorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
// writeAuthError writes an HTTP JSON error response with the provided status and error details.
// It sets the Content-Type header to "application/json", writes the HTTP status, and encodes an
// ErrorResponse containing the given code and message.
func writeAuthError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(ErrorResponse{
Error: ErrorDetail{
Code: code,
Message: message,
},
})
}

View File

@ -20,7 +20,9 @@ import (
"github.com/dagu-org/dagu/internal/common/logger"
"github.com/dagu-org/dagu/internal/common/logger/tag"
"github.com/dagu-org/dagu/internal/core/execution"
"github.com/dagu-org/dagu/internal/persistence/fileuser"
"github.com/dagu-org/dagu/internal/runtime"
authservice "github.com/dagu-org/dagu/internal/service/auth"
"github.com/dagu-org/dagu/internal/service/coordinator"
apiv1 "github.com/dagu-org/dagu/internal/service/frontend/api/v1"
apiv2 "github.com/dagu-org/dagu/internal/service/frontend/api/v2"
@ -45,14 +47,30 @@ type Server struct {
// NewServer constructs a Server configured from cfg and the provided stores, managers, and services.
// It extracts remote node names from cfg.Server.RemoteNodes, initializes apiV1 and apiV2 with the given dependencies, and populates the Server's funcsConfig fields from cfg.
func NewServer(cfg *config.Config, dr execution.DAGStore, drs execution.DAGRunStore, qs execution.QueueStore, ps execution.ProcStore, drm runtime.Manager, cc coordinator.Client, sr execution.ServiceRegistry, mr *prometheus.Registry, rs *resource.Service) *Server {
// Returns an error if a configured auth service fails to initialize (fail-fast behavior).
func NewServer(cfg *config.Config, dr execution.DAGStore, drs execution.DAGRunStore, qs execution.QueueStore, ps execution.ProcStore, drm runtime.Manager, cc coordinator.Client, sr execution.ServiceRegistry, mr *prometheus.Registry, rs *resource.Service) (*Server, error) {
var remoteNodes []string
for _, n := range cfg.Server.RemoteNodes {
remoteNodes = append(remoteNodes, n.Name)
}
// Build API options
var apiOpts []apiv2.APIOption
// Initialize auth service if builtin mode is enabled
if cfg.Server.Auth.Mode == config.AuthModeBuiltin {
authSvc, err := initBuiltinAuthService(cfg)
if err != nil {
// Fail fast: if auth is configured but fails to initialize, return error
// to prevent server from running without expected authentication
return nil, fmt.Errorf("failed to initialize builtin auth service: %w", err)
}
apiOpts = append(apiOpts, apiv2.WithAuthService(authSvc))
}
return &Server{
apiV1: apiv1.New(dr, drs, drm, cfg),
apiV2: apiv2.New(dr, drs, qs, ps, drm, cfg, cc, sr, mr, rs),
apiV2: apiv2.New(dr, drs, qs, ps, drm, cfg, cc, sr, mr, rs, apiOpts...),
config: cfg,
funcsConfig: funcsConfig{
NavbarColor: cfg.UI.NavbarColor,
@ -65,8 +83,65 @@ func NewServer(cfg *config.Config, dr execution.DAGStore, drs execution.DAGRunSt
RemoteNodes: remoteNodes,
Permissions: cfg.Server.Permissions,
Paths: cfg.Paths,
AuthMode: cfg.Server.Auth.Mode,
},
}, nil
}
// initBuiltinAuthService creates a file-based user store, constructs the builtin
// authentication service, and ensures a default admin user exists.
// If the admin password is auto-generated, the password is printed to stdout.
// It returns the initialized auth service or an error if any step fails.
func initBuiltinAuthService(cfg *config.Config) (*authservice.Service, error) {
ctx := context.Background()
// Validate token secret is configured
if cfg.Server.Auth.Builtin.Token.Secret == "" {
return nil, fmt.Errorf("builtin auth requires a non-empty token secret (set DAGU_AUTH_TOKEN_SECRET or server.auth.builtin.token.secret)")
}
// Create file-based user store
userStore, err := fileuser.New(cfg.Paths.UsersDir)
if err != nil {
return nil, fmt.Errorf("failed to create user store: %w", err)
}
// Create auth service with configuration
authConfig := authservice.Config{
TokenSecret: cfg.Server.Auth.Builtin.Token.Secret,
TokenTTL: cfg.Server.Auth.Builtin.Token.TTL,
}
authSvc := authservice.New(userStore, authConfig)
// Ensure admin user exists
password, created, err := authSvc.EnsureAdminUser(
ctx,
cfg.Server.Auth.Builtin.Admin.Username,
cfg.Server.Auth.Builtin.Admin.Password,
)
if err != nil {
return nil, fmt.Errorf("failed to ensure admin user: %w", err)
}
if created {
if cfg.Server.Auth.Builtin.Admin.Password == "" {
// Password was auto-generated, print to stdout (not to structured logs)
// which may be shipped to external systems)
fmt.Printf("\n"+
"================================================================================\n"+
" ADMIN USER CREATED\n"+
" Username: %s\n"+
" Password: %s\n"+
" NOTE: Please change this password immediately!\n"+
"================================================================================\n\n",
cfg.Server.Auth.Builtin.Admin.Username, password)
} else {
logger.Info(ctx, "Created admin user",
slog.String("username", cfg.Server.Auth.Builtin.Admin.Username))
}
}
return authSvc, nil
}
// Serve starts the HTTP server and configures routes
@ -235,6 +310,12 @@ func (srv *Server) setupAPIRoutes(ctx context.Context, r *chi.Mux, apiV1BasePath
var setupErr error
r.Route(apiV1BasePath, func(r chi.Router) {
if srv.config.Server.Auth.Mode != config.AuthModeNone {
// v1 API is not available in auth mode - it doesn't support authentication
logger.Info(ctx, "Authentication enabled: V1 API is disabled, use V2 API instead",
slog.String("authMode", string(srv.config.Server.Auth.Mode)))
return
}
url := fmt.Sprintf("%s://%s:%d%s", schema, srv.config.Server.Host, srv.config.Server.Port, apiV1BasePath)
if err := srv.apiV1.ConfigureRoutes(r, url); err != nil {
logger.Error(ctx, "Failed to configure v1 API routes", tag.Error(err))

View File

@ -60,8 +60,10 @@ type funcsConfig struct {
RemoteNodes []string
Permissions map[config.Permission]bool
Paths config.PathsConfig
AuthMode config.AuthMode
}
// and simple utility helpers for use inside HTML templates.
func defaultFunctions(cfg funcsConfig) template.FuncMap {
return template.FuncMap{
"defTitle": func(ip any) string {
@ -134,6 +136,12 @@ func defaultFunctions(cfg funcsConfig) template.FuncMap {
"pathConfigFileUsed": func() string {
return cfg.Paths.ConfigFileUsed
},
"pathUsersDir": func() string {
return cfg.Paths.UsersDir
},
"authMode": func() string {
return string(cfg.AuthMode)
},
}
}

View File

@ -19,6 +19,7 @@
tzOffsetInSec: +"{{ tzOffsetInSec }}",
maxDashboardPageLimit: "{{ maxDashboardPageLimit }}",
remoteNodes: "{{ remoteNodes }}",
authMode: "{{ authMode }}",
permissions: {
writeDags: "{{ permissionsWriteDags }}" === "true",
runDags: "{{ permissionsRunDags }}" === "true",
@ -34,6 +35,7 @@
procDir: "{{ pathProcDir }}",
serviceRegistryDir: "{{ pathServiceRegistryDir }}",
configFileUsed: "{{ pathConfigFileUsed }}",
usersDir: "{{ pathUsersDir }}",
},
};
}

View File

@ -64,8 +64,9 @@ func (srv *Server) runServer(t *testing.T) {
)
mr := telemetry.NewRegistry(collector)
server := frontend.NewServer(srv.Config, srv.DAGStore, srv.DAGRunStore, srv.QueueStore, srv.ProcStore, srv.DAGRunMgr, cc, srv.ServiceRegistry, mr, nil)
err := server.Serve(srv.Context)
server, err := frontend.NewServer(srv.Config, srv.DAGStore, srv.DAGRunStore, srv.QueueStore, srv.ProcStore, srv.DAGRunMgr, cc, srv.ServiceRegistry, mr, nil)
require.NoError(t, err, "failed to create server")
err = server.Serve(srv.Context)
require.NoError(t, err, "failed to start server")
}

View File

@ -47,7 +47,9 @@
queueDir: '~/.local/share/dagu/data/queue',
procDir: '~/.local/share/dagu/data/proc',
serviceRegistryDir: '~/.local/share/dagu/data/services',
usersDir: '~/.local/share/dagu/data/users',
},
authMode: 'builtin',
};
}
</script>

View File

@ -73,6 +73,7 @@
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",

View File

@ -23,6 +23,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -1174,6 +1177,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dropdown-menu@2.1.16':
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.2':
resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==}
peerDependencies:
@ -1227,6 +1243,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-menu@2.1.16':
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.7':
resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
peerDependencies:
@ -1240,6 +1269,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@ -1292,6 +1334,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.5':
resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==}
peerDependencies:
@ -5648,6 +5703,21 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)':
dependencies:
react: 19.1.0
@ -5687,6 +5757,32 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
aria-hidden: 1.2.6
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-remove-scroll: 2.7.1(@types/react@19.1.8)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -5705,6 +5801,24 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/rect': 1.1.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -5744,6 +5858,23 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-select@2.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/number': 1.1.1

View File

@ -6,6 +6,8 @@ import { AppBarContext } from './contexts/AppBarContext';
import { Config, ConfigContext } from './contexts/ConfigContext';
import { SearchStateProvider } from './contexts/SearchStateContext';
import { UserPreferencesProvider } from './contexts/UserPreference';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import Layout from './layouts/Layout';
import fetchJson from './lib/fetchJson';
import Dashboard from './pages';
@ -17,11 +19,22 @@ import DAGRunDetails from './pages/dag-runs/dag-run';
import Queues from './pages/queues';
import Workers from './pages/workers';
import SystemStatus from './pages/system-status';
import LoginPage from './pages/login';
import UsersPage from './pages/users';
type Props = {
config: Config;
};
/**
* Root application component that composes providers, routing, and global UI state.
*
* Initializes and persists the selected remote node, exposes app bar state and config
* via context providers, and mounts public (login) and protected routes inside the app layout.
*
* @param config - Application configuration (e.g., `basePath`, `remoteNodes`) used to configure routing and available remote nodes.
* @returns The top-level React element for the application.
*/
function App({ config }: Props) {
const [title, setTitle] = React.useState<string>('');
@ -90,33 +103,57 @@ function App({ config }: Props) {
>
<ConfigContext.Provider value={config}>
<UserPreferencesProvider>
<SearchStateProvider>
<ToastProvider>
<BrowserRouter basename={config.basePath}>
<Layout {...config}>
<AuthProvider>
<SearchStateProvider>
<ToastProvider>
<BrowserRouter basename={config.basePath}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/system-status" element={<SystemStatus />} />
<Route path="/dags/" element={<DAGs />} />
{/* Public route - Login page */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route
path="/dags/:fileName/:tab"
element={<DAGDetails />}
path="/*"
element={
<ProtectedRoute>
<Layout {...config}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/system-status" element={<SystemStatus />} />
<Route path="/dags/" element={<DAGs />} />
<Route
path="/dags/:fileName/:tab"
element={<DAGDetails />}
/>
<Route path="/dags/:fileName/" element={<DAGDetails />} />
<Route path="/search/" element={<Search />} />
<Route path="/queues" element={<Queues />} />
<Route path="/dag-runs" element={<DAGRuns />} />
<Route
path="/dag-runs/:name/:dagRunId"
element={<DAGRunDetails />}
/>
<Route path="/workers" element={<Workers />} />
{/* Admin-only route */}
<Route
path="/users"
element={
<ProtectedRoute requiredRole="admin">
<UsersPage />
</ProtectedRoute>
}
/>
</Routes>
</Layout>
</ProtectedRoute>
}
/>
<Route path="/dags/:fileName/" element={<DAGDetails />} />
<Route path="/search/" element={<Search />} />
<Route path="/queues" element={<Queues />} />
<Route path="/dag-runs" element={<DAGRuns />} />
<Route
path="/dag-runs/:name/:dagRunId"
element={<DAGRunDetails />}
/>
<Route path="/workers" element={<Workers />} />
</Routes>
</Layout>
</BrowserRouter>
</ToastProvider>
</SearchStateProvider>
</BrowserRouter>
</ToastProvider>
</SearchStateProvider>
</AuthProvider>
</UserPreferencesProvider>
</ConfigContext.Provider>
</AppBarContext.Provider>
@ -124,4 +161,4 @@ function App({ config }: Props) {
);
}
export default App;
export default App;

View File

@ -24,6 +24,138 @@ export interface paths {
patch?: never;
trace?: never;
};
"/auth/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Authenticate user and obtain JWT token
* @description Authenticates a user with username and password, returns a JWT token on success
*/
post: operations["login"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get current authenticated user
* @description Returns information about the currently authenticated user
*/
get: operations["getCurrentUser"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/change-password": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Change current user's password
* @description Allows the authenticated user to change their own password
*/
post: operations["changePassword"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List all users
* @description Returns a list of all users. Requires admin role.
*/
get: operations["listUsers"];
put?: never;
/**
* Create a new user
* @description Creates a new user account. Requires admin role.
*/
post: operations["createUser"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{userId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get user by ID
* @description Returns a specific user by their ID. Requires admin role.
*/
get: operations["getUser"];
put?: never;
post?: never;
/**
* Delete user
* @description Deletes a user account. Requires admin role. Cannot delete yourself.
*/
delete: operations["deleteUser"];
options?: never;
head?: never;
/**
* Update user
* @description Updates a user's information. Requires admin role.
*/
patch: operations["updateUser"];
trace?: never;
};
"/users/{userId}/reset-password": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Reset user's password
* @description Resets a user's password to a new value. Requires admin role.
*/
post: operations["resetUserPassword"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/workers": {
parameters: {
query?: never;
@ -1299,11 +1431,93 @@ export interface components {
/** Format: double */
value: number;
};
/**
* @description User role determining access permissions. admin: full access including user management, manager: DAG CRUD and execution, operator: DAG execution only, viewer: read-only
* @enum {string}
*/
UserRole: UserRole;
/** @description Request body for user login */
LoginRequest: {
/** @description User's username */
username: string;
/** @description User's password */
password: string;
};
/** @description Response containing authentication token */
LoginResponse: {
/** @description JWT authentication token */
token: string;
/**
* Format: date-time
* @description Token expiration timestamp
*/
expiresAt: string;
user: components["schemas"]["User"];
};
/** @description Request body for changing password */
ChangePasswordRequest: {
/** @description Current password for verification */
currentPassword: string;
/** @description New password to set */
newPassword: string;
};
/** @description Request body for admin password reset */
ResetPasswordRequest: {
/** @description New password to set for the user */
newPassword: string;
};
/** @description Request body for creating a new user */
CreateUserRequest: {
/** @description Unique username */
username: string;
/** @description User's password */
password: string;
role: components["schemas"]["UserRole"];
};
/** @description Request body for updating a user */
UpdateUserRequest: {
/** @description New username (must be unique) */
username?: string;
role?: components["schemas"]["UserRole"];
};
/** @description User information */
User: {
/** @description Unique user identifier */
id: string;
/** @description User's username */
username: string;
role: components["schemas"]["UserRole"];
/**
* Format: date-time
* @description Account creation timestamp
*/
createdAt: string;
/**
* Format: date-time
* @description Last update timestamp
*/
updatedAt: string;
};
/** @description Response containing user information */
UserResponse: {
user: components["schemas"]["User"];
};
/** @description Response containing list of users */
UsersListResponse: {
users: components["schemas"]["User"][];
};
/** @description Generic success response */
SuccessResponse: {
/** @description Success message */
message: string;
};
};
responses: never;
parameters: {
/** @description page number of items to fetch (default is 1) */
Page: number;
/** @description unique identifier of the user */
UserId: string;
/** @description number of items per page (default is 30, max is 100) */
PerPage: number;
/** @description the name of the DAG file */
@ -1370,6 +1584,522 @@ export interface operations {
};
};
};
login: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginRequest"];
};
};
responses: {
/** @description Authentication successful */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LoginResponse"];
};
};
/** @description Invalid credentials */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
getCurrentUser: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Current user information */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserResponse"];
};
};
/** @description Not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
changePassword: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChangePasswordRequest"];
};
};
responses: {
/** @description Password changed successfully */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SuccessResponse"];
};
};
/** @description Invalid request (e.g., weak password) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not authenticated or wrong current password */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
listUsers: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description List of users */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UsersListResponse"];
};
};
/** @description Not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Forbidden - requires admin role */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
createUser: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateUserRequest"];
};
};
responses: {
/** @description User created successfully */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserResponse"];
};
};
/** @description Invalid request (e.g., weak password, invalid role) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Forbidden - requires admin role */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Conflict - username already exists */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
getUser: {
parameters: {
query?: never;
header?: never;
path: {
/** @description unique identifier of the user */
userId: components["parameters"]["UserId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description User details */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserResponse"];
};
};
/** @description Not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Forbidden - requires admin role */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description User not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
deleteUser: {
parameters: {
query?: never;
header?: never;
path: {
/** @description unique identifier of the user */
userId: components["parameters"]["UserId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description User deleted successfully */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Forbidden - requires admin role or cannot delete self */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description User not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
updateUser: {
parameters: {
query?: never;
header?: never;
path: {
/** @description unique identifier of the user */
userId: components["parameters"]["UserId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateUserRequest"];
};
};
responses: {
/** @description User updated successfully */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserResponse"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Forbidden - requires admin role */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description User not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Conflict - username already exists */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
resetUserPassword: {
parameters: {
query?: never;
header?: never;
path: {
/** @description unique identifier of the user */
userId: components["parameters"]["UserId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ResetPasswordRequest"];
};
};
responses: {
/** @description Password reset successfully */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SuccessResponse"];
};
};
/** @description Invalid request (e.g., weak password) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Forbidden - requires admin role */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description User not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unexpected error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
getWorkers: {
parameters: {
query?: {
@ -3329,3 +4059,9 @@ export enum QueueType {
global = "global",
dag_based = "dag-based"
}
export enum UserRole {
admin = "admin",
manager = "manager",
operator = "operator",
viewer = "viewer"
}

View File

@ -0,0 +1,187 @@
import { useState } from 'react';
import { useConfig } from '@/contexts/ConfigContext';
import { useAuth } from '@/contexts/AuthContext';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle, CheckCircle } from 'lucide-react';
type ChangePasswordModalProps = {
open: boolean;
onClose: () => void;
};
/**
* Render a modal dialog that lets the current user change their password.
*
* Validates input (matching confirmation and minimum length), submits the change to the configured API, and displays error or success state. The modal resets its form when closed.
*
* @param open - Whether the modal is visible.
* @param onClose - Callback invoked when the modal is closed.
* @returns The Change Password modal React element.
*/
export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps) {
const config = useConfig();
const { token } = useAuth();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const resetForm = () => {
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
setSuccess(false);
};
const handleClose = () => {
resetForm();
onClose();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setIsLoading(true);
try {
const response = await fetch(`${config.apiURL}/auth/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
currentPassword,
newPassword,
}),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
// Check for nested error message in API response format
const message = data.message || data.error?.message || 'Failed to change password';
throw new Error(message);
}
setSuccess(true);
setTimeout(() => {
handleClose();
}, 1500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change password');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Password</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 text-sm text-green-600 dark:text-green-400 bg-green-500/10 rounded-md">
<CheckCircle className="h-4 w-4 flex-shrink-0" />
<span>Password changed successfully!</span>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="current-password" className="text-sm">
Current Password
</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password" className="text-sm">
New Password
</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password" className="text-sm">
Confirm New Password
</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
className="h-9"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="h-8"
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || success}
className="h-8"
>
{isLoading ? 'Changing...' : 'Change Password'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,45 @@
import { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth, hasRole } from '@/contexts/AuthContext';
import { useConfig } from '@/contexts/ConfigContext';
type ProtectedRouteProps = {
children: ReactNode;
requiredRole?: 'admin' | 'manager' | 'operator' | 'viewer';
};
/**
* Renders `children` only when built-in authentication and optional role checks permit access; otherwise performs the appropriate redirect or renders nothing while auth state is loading.
*
* If `config.authMode` is not `'builtin'`, access is allowed and `children` are rendered. While auth state is loading the component renders `null`. If the user is not authenticated it redirects to `/login` and preserves the current location for post-login navigation. If a `requiredRole` is provided and the authenticated user lacks that role it redirects to `/`.
*
* @param requiredRole - Optional role required to access the route; one of `'admin' | 'manager' | 'operator' | 'viewer'`.
* @returns The `children` element when access is allowed, `null` while auth state is loading, or a `Navigate` element that redirects the user when access is denied.
*/
export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
const config = useConfig();
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
// If auth mode is not builtin, allow access
if (config.authMode !== 'builtin') {
return <>{children}</>;
}
// Show nothing while loading auth state
if (isLoading) {
return null;
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Check role requirement if specified
if (requiredRole && user && !hasRole(user.role, requiredRole)) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View File

@ -0,0 +1,101 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { useConfig } from '@/contexts/ConfigContext';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { User, LogOut, Key, Shield } from 'lucide-react';
import { ChangePasswordModal } from './ChangePasswordModal';
/**
* Renders a user dropdown menu with profile info, a change-password action, and sign-out.
*
* The menu is rendered only when built-in authentication is enabled and a user is authenticated.
*
* @returns The user menu JSX element when shown, or `null` when authentication is not available.
*/
export function UserMenu() {
const { user, logout, isAuthenticated } = useAuth();
const config = useConfig();
const navigate = useNavigate();
const [showChangePassword, setShowChangePassword] = useState(false);
// Don't show if auth is not builtin or user is not authenticated
if (config.authMode !== 'builtin' || !isAuthenticated || !user) {
return null;
}
const handleLogout = () => {
logout();
navigate('/login');
};
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-red-500/20 text-red-600 dark:text-red-400';
case 'manager':
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
case 'operator':
return 'bg-green-500/20 text-green-600 dark:text-green-400';
case 'viewer':
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
default:
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
}
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-current hover:bg-accent/10"
>
<User className="h-4 w-4 mr-1.5" />
<span className="text-sm">{user.username}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{user.username}</p>
<div className="flex items-center gap-1.5">
<Shield className="h-3 w-3 text-muted-foreground" />
<span
className={`text-xs px-1.5 py-0.5 rounded-full capitalize ${getRoleBadgeColor(user.role)}`}
>
{user.role}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowChangePassword(true)}>
<Key className="h-4 w-4 mr-2" />
Change Password
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400">
<LogOut className="h-4 w-4 mr-2" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChangePasswordModal
open={showChangePassword}
onClose={() => setShowChangePassword(false)}
/>
</>
);
}

View File

@ -0,0 +1,197 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<span className="ml-auto h-4 w-4"></span>
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<span></span>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<span></span>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -0,0 +1,196 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useConfig } from './ConfigContext';
type UserRole = 'admin' | 'manager' | 'operator' | 'viewer';
type User = {
id: string;
username: string;
role: UserRole;
};
type AuthContextType = {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | null>(null);
export const TOKEN_KEY = 'dagu_auth_token';
// Role hierarchy for permission checking
const ROLE_HIERARCHY: Record<UserRole, number> = {
admin: 4,
manager: 3,
operator: 2,
viewer: 1,
};
/**
* Provides authentication state and actions to descendant components.
*
* Exposes context values for the current user, auth token, authentication status,
* loading state, and functions to log in, log out, and refresh the user from the API.
* The provider persists the auth token to localStorage and respects the configured
* authentication mode when initializing or refreshing user state.
*
* @param children - React nodes that receive the authentication context
* @returns The context provider element that supplies authentication state and actions
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const config = useConfig();
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [isLoading, setIsLoading] = useState(true);
const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
}, []);
const refreshUser = useCallback(async () => {
const storedToken = localStorage.getItem(TOKEN_KEY);
if (!storedToken) {
setIsLoading(false);
return;
}
try {
const response = await fetch(`${config.apiURL}/auth/me`, {
headers: {
Authorization: `Bearer ${storedToken}`,
},
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
setToken(storedToken);
} else {
logout();
}
} catch {
logout();
} finally {
setIsLoading(false);
}
}, [config.apiURL, logout]);
const login = useCallback(async (username: string, password: string) => {
const response = await fetch(`${config.apiURL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Login failed');
}
const data = await response.json();
localStorage.setItem(TOKEN_KEY, data.token);
setToken(data.token);
setUser(data.user);
}, [config.apiURL]);
useEffect(() => {
if (config.authMode === 'builtin') {
refreshUser();
} else {
setIsLoading(false);
}
}, [config.authMode, refreshUser]);
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!user,
isLoading,
login,
logout,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}
/**
* Access the authentication context supplied by the nearest AuthProvider in the React tree.
*
* @returns The AuthContext value containing `user`, `token`, `isAuthenticated`, `isLoading`, `login`, `logout`, and `refreshUser`.
* @throws Error if there is no surrounding AuthProvider
*/
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
/**
* Determines whether the current user has administrative privileges.
*
* @returns `true` if the app is using a non-'builtin' auth mode or the authenticated user's role is `'admin'`, `false` otherwise.
*/
export function useIsAdmin(): boolean {
const { user } = useAuth();
const config = useConfig();
if (config.authMode !== 'builtin') return true;
return user?.role === 'admin';
}
/**
* Determines whether the current user is permitted to create or modify DAGs.
*
* In non-builtin auth mode this follows the `config.permissions.writeDags` flag.
* In builtin auth mode the user must exist and have role `manager` or `admin`.
*
* @returns `true` if writing DAGs is permitted in the current context, `false` otherwise.
*/
export function useCanWrite(): boolean {
const { user } = useAuth();
const config = useConfig();
if (config.authMode !== 'builtin') return config.permissions.writeDags;
if (!user) return false;
return ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY['manager'];
}
/**
* Determine whether the current user is permitted to execute (run) DAGs.
*
* In non-builtin auth mode this reflects `config.permissions.runDags`; in builtin mode this requires the user's role to be `operator` or higher.
*
* @returns `true` if execution is permitted for the current context, `false` otherwise.
*/
export function useCanExecute(): boolean {
const { user } = useAuth();
const config = useConfig();
if (config.authMode !== 'builtin') return config.permissions.runDags;
if (!user) return false;
return ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY['operator'];
}
/**
* Determine whether a user's role meets or exceeds a required role in the role hierarchy.
*
* @param userRole - The role held by the user
* @param requiredRole - The minimum role required
* @returns `true` if `userRole` has at least the permissions of `requiredRole`, `false` otherwise.
*/
export function hasRole(userRole: UserRole, requiredRole: UserRole): boolean {
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
}

View File

@ -13,6 +13,8 @@ export type PathsConfig = {
configFileUsed: string;
};
export type AuthMode = 'none' | 'builtin' | 'oidc' | '';
export type Config = {
apiURL: string;
basePath: string;
@ -23,6 +25,7 @@ export type Config = {
version: string;
maxDashboardPageLimit: number;
remoteNodes: string;
authMode: AuthMode;
permissions: {
writeDags: boolean;
runDags: boolean;

View File

@ -73,6 +73,7 @@ const ResourceChart: React.FC<ResourceChartProps> = ({
const lastPoint = formattedData[formattedData.length - 1];
const currentValue = lastPoint ? lastPoint.value : 0;
const gradientId = `gradient-${title.replace(/\s+/g, '-')}`;
return (
<Card>
@ -96,15 +97,9 @@ const ResourceChart: React.FC<ResourceChartProps> = ({
}}
>
<defs>
<linearGradient
id={`color-${title}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid
@ -127,8 +122,8 @@ const ResourceChart: React.FC<ResourceChartProps> = ({
type="monotone"
dataKey="value"
stroke={color}
fillOpacity={1}
fill={`url(#color-${title})`}
strokeWidth={2}
fill={`url(#${gradientId})`}
/>
</AreaChart>
</ResponsiveContainer>

View File

@ -1,4 +1,4 @@
import createClient from 'openapi-fetch';
import createClient, { Middleware } from 'openapi-fetch';
import {
createQueryHook,
createImmutableHook,
@ -8,9 +8,21 @@ import {
import { isMatch } from 'lodash-es';
import type { paths } from '../api/v2/schema';
const authMiddleware: Middleware = {
async onRequest({ request }) {
const token = localStorage.getItem('dagu_auth_token');
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
return request;
},
};
const client = createClient<paths>({
baseUrl: getConfig().apiURL,
});
client.use(authMiddleware);
const prefix = '/';
export const useQuery = createQueryHook(client, prefix);

View File

@ -9,10 +9,20 @@ import { cn } from '@/lib/utils';
import { Menu, X } from 'lucide-react';
import * as React from 'react';
import { AppBarContext } from '../contexts/AppBarContext';
import { useConfig } from '../contexts/ConfigContext';
import { mainListItems as MainListItems } from '../menu';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { UserMenu } from '@/components/UserMenu';
// Utility: Get contrast color (black or white) for a given background color (hex, rgb, or named)
/**
* Choose a readable foreground color (black or white) that contrasts with the given background color.
*
* Accepts CSS color inputs such as hex (#rgb or #rrggbb), named colors, or rgb/rgba strings. When the input
* cannot be parsed or when executed outside a browser environment, a conservative fallback is used.
*
* @param input - Background color value to evaluate (hex string, named color, or rgb/rgba). If omitted or empty, black is assumed.
* @returns `'#000'` for a dark foreground (black) when the background is light, `'#fff'` for a light foreground (white) when the background is dark.
*/
function getContrastColor(input?: string): string {
if (!input) return '#000'; // Default to black if undefined or empty
@ -75,8 +85,19 @@ type LayoutProps = {
children?: React.ReactElement | React.ReactElement[];
};
// Main Content component including Sidebar and AppBar logic
/**
* Render the application's main layout including a responsive sidebar, app bar, and scrollable content area.
*
* The header uses `navbarColor` when provided and computes an appropriate contrast color for its text.
* The desktop sidebar expansion state is persisted to `localStorage` under `sidebarExpanded`.
*
* @param title - Text displayed in the app bar as the primary title
* @param navbarColor - Optional CSS color used as the app bar background; contrast text color is derived automatically
* @param children - Content rendered in the main scrollable area of the layout
* @returns The JSX element for the full layout (sidebar, app bar, and main content)
*/
function Content({ title, navbarColor, children }: LayoutProps) {
const config = useConfig();
const [scrolled, setScrolled] = React.useState(false);
// Sidebar state with localStorage persistence
const [isSidebarExpanded, setIsSidebarExpanded] = React.useState(() => {
@ -227,7 +248,9 @@ function Content({ title, navbarColor, children }: LayoutProps) {
<div className="flex items-center space-x-2">
<AppBarContext.Consumer>
{(context) => {
// Hide remote node selector when builtin auth is enabled (auth is local-only)
if (
config.authMode === 'builtin' ||
!context.remoteNodes ||
context.remoteNodes.length === 0
) {
@ -257,6 +280,7 @@ function Content({ title, navbarColor, children }: LayoutProps) {
}}
</AppBarContext.Consumer>
<ThemeToggle />
<UserMenu />
</div>
</div>
</header>
@ -304,4 +328,4 @@ const NavBarTitleText = ({
// Default export Layout component
export default function Layout({ children, ...props }: LayoutProps) {
return <Content {...props}>{children}</Content>;
}
}

View File

@ -1,13 +1,32 @@
/**
* Fetches JSON from the configured API base URL and returns the parsed response body.
*
* This function appends `input` to `getConfig().apiURL`, merges any provided `init.headers`
* with `Accept: application/json`, and, if present, adds an `Authorization: Bearer <token>`
* header using the token stored in localStorage under `dagu_auth_token`.
*
* @param input - The request URL or RequestInfo which will be appended to the API base URL
* @param init - Optional fetch init options; provided headers are merged with the function's headers
* @returns The parsed response body as JSON
* @throws FetchError when the response has a non-OK status; the error includes the original `Response` and the parsed response body
*/
export default async function fetchJson<JSON = unknown>(
input: RequestInfo,
init?: RequestInit
): Promise<JSON> {
const token = localStorage.getItem('dagu_auth_token');
const headers: HeadersInit = {
...(init?.headers || {}),
Accept: 'application/json',
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${getConfig().apiURL}${input}`, {
...init,
headers: {
...(init?.headers || {}),
Accept: 'application/json',
},
headers,
});
const data = await response.json();
@ -48,4 +67,4 @@ export class FetchError extends Error {
this.response = response;
this.data = data ?? { message: message };
}
}
}

View File

@ -1,5 +1,6 @@
import logoDark from '@/assets/images/logo_dark.png';
import { useConfig } from '@/contexts/ConfigContext';
import { useIsAdmin } from '@/contexts/AuthContext';
import { cn } from '@/lib/utils'; // Assuming cn utility is available
import {
Activity,
@ -10,6 +11,7 @@ import {
PanelLeft,
Search,
Server,
Users,
Workflow,
} from 'lucide-react';
import * as React from 'react';
@ -65,6 +67,7 @@ export const mainListItems = React.forwardRef<
>(({ isOpen = false, onNavItemClick, onToggle }, ref) => {
// Get version from config at the top level of the component
const config = useConfig();
const isAdmin = useIsAdmin();
// State for hover
const [isHovered, setIsHovered] = React.useState(false);
@ -216,6 +219,15 @@ export const mainListItems = React.forwardRef<
isOpen={isOpen}
onClick={onNavItemClick}
/>
{isAdmin && (
<NavItem
to="/users"
text="User Management"
icon={<Users size={18} />}
isOpen={isOpen}
onClick={onNavItemClick}
/>
)}
</div>
</nav>
{/* Discord Community link */}

107
ui/src/pages/login.tsx Normal file
View File

@ -0,0 +1,107 @@
import { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { useConfig } from '@/contexts/ConfigContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react';
/**
* Renders the login page UI and handles authentication flow.
*
* The component shows username and password fields, displays an error banner when login fails,
* and redirects the user to the intended destination after successful authentication.
*
* @returns The rendered login page React element.
*/
export default function LoginPage() {
const config = useConfig();
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const from = (location.state as { from?: Location })?.from?.pathname || '/';
// Redirect if already authenticated - use useEffect to avoid render-phase side effects
useEffect(() => {
if (isAuthenticated) {
navigate(from, { replace: true });
}
}, [isAuthenticated, navigate, from]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
await login(username, password);
navigate(from, { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-full max-w-sm p-6 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">{config.title || 'Dagu'}</h1>
<p className="text-sm text-muted-foreground">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="username" className="text-sm">
Username
</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password" className="text-sm">
Password
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="h-9"
/>
</div>
<Button type="submit" className="w-full h-9" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,174 @@
import { useState } from 'react';
import { useConfig } from '@/contexts/ConfigContext';
import { components } from '@/api/v2/schema';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle, CheckCircle } from 'lucide-react';
type User = components['schemas']['User'];
type ResetPasswordModalProps = {
open: boolean;
user?: User;
onClose: () => void;
};
/**
* Renders a modal dialog allowing an administrator to set a new password for a user.
*
* Validates that the new and confirm passwords match and are at least 8 characters, sends
* a PUT request to update the user's password using the auth token from localStorage, and
* shows inline error or success feedback. The dialog resets its form state when closed.
*
* @param open - Whether the dialog is visible
* @param user - The target user whose password will be reset
* @param onClose - Callback invoked when the dialog is closed
* @returns The Reset Password modal component
*/
export function ResetPasswordModal({ open, user, onClose }: ResetPasswordModalProps) {
const config = useConfig();
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const resetForm = () => {
setNewPassword('');
setConfirmPassword('');
setError(null);
setSuccess(false);
};
const handleClose = () => {
resetForm();
onClose();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!user) return;
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setIsLoading(true);
try {
const token = localStorage.getItem('dagu_auth_token');
const response = await fetch(`${config.apiURL}/users/${user.id}/password`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ newPassword }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to reset password');
}
setSuccess(true);
setTimeout(() => {
handleClose();
}, 1500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset password');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset Password for {user?.username}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 text-sm text-green-600 dark:text-green-400 bg-green-500/10 rounded-md">
<CheckCircle className="h-4 w-4 flex-shrink-0" />
<span>Password reset successfully!</span>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="new-password" className="text-sm">
New Password
</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
className="h-9"
placeholder="Minimum 8 characters"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password" className="text-sm">
Confirm Password
</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
className="h-9"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="h-8"
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || success}
className="h-8"
>
{isLoading ? 'Resetting...' : 'Reset Password'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import { useConfig } from '@/contexts/ConfigContext';
import { components } from '@/api/v2/schema';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AlertCircle } from 'lucide-react';
type User = components['schemas']['User'];
type UserFormModalProps = {
open: boolean;
user?: User;
onClose: () => void;
onSuccess: () => void;
};
const ROLES = [
{ value: 'admin', label: 'Admin', description: 'Full access including user management' },
{ value: 'manager', label: 'Manager', description: 'DAG create/edit/delete and execution' },
{ value: 'operator', label: 'Operator', description: 'DAG execution only' },
{ value: 'viewer', label: 'Viewer', description: 'Read-only access' },
] as const;
/**
* Render a modal dialog that provides a form to create a new user or edit an existing one.
*
* @param props.open - Whether the modal is open.
* @param props.user - Existing user to edit; when undefined the form operates in create mode.
* @param props.onClose - Callback invoked when the modal is closed.
* @param props.onSuccess - Callback invoked after a successful create or update operation.
* @returns The modal JSX element containing the user form.
*/
export function UserFormModal({ open, user, onClose, onSuccess }: UserFormModalProps) {
const config = useConfig();
const isEditing = !!user;
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<string>('viewer');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (user) {
setUsername(user.username);
setRole(user.role);
setPassword('');
} else {
setUsername('');
setPassword('');
setRole('viewer');
}
setError(null);
}, [user, open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!isEditing && password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setIsLoading(true);
try {
const token = localStorage.getItem('dagu_auth_token');
if (isEditing) {
// Update user
const response = await fetch(`${config.apiURL}/users/${user.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ username, role }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to update user');
}
} else {
// Create user
const response = await fetch(`${config.apiURL}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ username, password, role }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to create user');
}
}
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : 'Operation failed');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit User' : 'Create User'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="username" className="text-sm">
Username
</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="off"
className="h-9"
/>
</div>
{!isEditing && (
<div className="space-y-1.5">
<Label htmlFor="password" className="text-sm">
Password
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
className="h-9"
placeholder="Minimum 8 characters"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="role" className="text-sm">
Role
</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex flex-col">
<span>{r.label}</span>
<span className="text-xs text-muted-foreground">{r.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose} className="h-8">
Cancel
</Button>
<Button type="submit" disabled={isLoading} className="h-8">
{isLoading ? 'Saving...' : isEditing ? 'Update' : 'Create'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,267 @@
import { useState, useEffect, useCallback, useContext } from 'react';
import { useConfig } from '@/contexts/ConfigContext';
import { useAuth, TOKEN_KEY } from '@/contexts/AuthContext';
import { AppBarContext } from '@/contexts/AppBarContext';
import { components } from '@/api/v2/schema';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UserPlus, MoreHorizontal, Pencil, Trash2, Key, Shield } from 'lucide-react';
import { UserFormModal } from './UserFormModal';
import { ResetPasswordModal } from './ResetPasswordModal';
import ConfirmModal from '@/ui/ConfirmModal';
import dayjs from '@/lib/dayjs';
type User = components['schemas']['User'];
/**
* Render the Users management page with a table of accounts and controls for creating, editing, resetting passwords, and deleting users.
*
* This component sets the application bar title to "User Management", fetches the user list from the configured API using a stored token, and manages loading and error states. It highlights the current user, formats created/updated timestamps, and exposes per-user actions that open the appropriate modals (create, edit, reset password, delete). Deletion performs an API DELETE request and refreshes the list on success.
*
* @returns The Users page component as a JSX.Element
*/
export default function UsersPage() {
const config = useConfig();
const { user: currentUser } = useAuth();
const appBarContext = useContext(AppBarContext);
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
const [deletingUser, setDeletingUser] = useState<User | null>(null);
// Set page title
useEffect(() => {
appBarContext.setTitle('User Management');
}, [appBarContext]);
const fetchUsers = useCallback(async () => {
try {
const token = localStorage.getItem(TOKEN_KEY);
const response = await fetch(`${config.apiURL}/users`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data = await response.json();
setUsers(data.users || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users');
} finally {
setIsLoading(false);
}
}, [config.apiURL]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleDeleteUser = async () => {
if (!deletingUser) return;
try {
const token = localStorage.getItem(TOKEN_KEY);
const response = await fetch(`${config.apiURL}/users/${deletingUser.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to delete user');
}
setError(null); // Clear any previous error on success
setDeletingUser(null);
fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
}
};
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-red-500/20 text-red-600 dark:text-red-400';
case 'manager':
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
case 'operator':
return 'bg-green-500/20 text-green-600 dark:text-green-400';
case 'viewer':
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
default:
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">Users</h1>
<p className="text-sm text-muted-foreground">
Manage user accounts and their roles
</p>
</div>
<Button onClick={() => setShowCreateModal(true)} size="sm" className="h-8">
<UserPlus className="h-4 w-4 mr-1.5" />
Add User
</Button>
</div>
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Username</TableHead>
<TableHead className="w-[120px]">Role</TableHead>
<TableHead className="w-[180px]">Created</TableHead>
<TableHead className="w-[180px]">Updated</TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
Loading users...
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{user.username}
{user.id === currentUser?.id && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5 text-muted-foreground" />
<span
className={`text-xs px-1.5 py-0.5 rounded-full capitalize ${getRoleBadgeColor(user.role)}`}
>
{user.role}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{dayjs(user.createdAt).format('MMM D, YYYY HH:mm')}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{dayjs(user.updatedAt).format('MMM D, YYYY HH:mm')}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditingUser(user)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setResetPasswordUser(user)}>
<Key className="h-4 w-4 mr-2" />
Reset Password
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeletingUser(user)}
className="text-destructive"
disabled={user.id === currentUser?.id}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Create User Modal */}
<UserFormModal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
fetchUsers();
}}
/>
{/* Edit User Modal */}
<UserFormModal
open={!!editingUser}
user={editingUser || undefined}
onClose={() => setEditingUser(null)}
onSuccess={() => {
setEditingUser(null);
fetchUsers();
}}
/>
{/* Reset Password Modal */}
<ResetPasswordModal
open={!!resetPasswordUser}
user={resetPasswordUser || undefined}
onClose={() => setResetPasswordUser(null)}
/>
{/* Delete Confirmation */}
<ConfirmModal
title="Delete User"
buttonText="Delete"
visible={!!deletingUser}
dismissModal={() => setDeletingUser(null)}
onSubmit={handleDeleteUser}
>
<p>Are you sure you want to delete user &quot;{deletingUser?.username}&quot;? This action cannot be undone.</p>
</ConfirmModal>
</div>
);
}