Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.PHONY: backend frontend build docker test test-frontend test-all clean dev-backend dev-frontend dev-bundle lint lint-frontend lint-backend security govuln vuln secrets audit hooks check docs e2e-fresh stop

BACKEND_ENV ?= LISTEN_ADDR=:9096
BACKEND_ENV ?= LISTEN_ADDR=:9096 COOKIE_SECURE=false
BIN_DIR ?= $(PWD)/bin
BINARY ?= $(BIN_DIR)/warden

Expand Down
83 changes: 70 additions & 13 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}

// Set Cookie
http.SetCookie(w, &http.Cookie{
http.SetCookie(w, &http.Cookie{ // #nosec G124 -- Secure defaults true; configurable for local HTTP dev
Name: "auth_token",
Value: token,
Expires: expiresAt,
Expand All @@ -113,11 +113,38 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
Secure: h.config.CookieSecure,
})

// Fetch full user details for the response (avatar, display name, etc.)
fullUser, _ := h.store.GetUser(user.ID)
avatar := ""
displayName := user.Username
email := ""
timezone := "UTC"
ssoProvider := ""
if fullUser != nil {
displayName = fullUser.DisplayName
if displayName == "" {
displayName = fullUser.Username
}
email = fullUser.Email
timezone = fullUser.Timezone
ssoProvider = fullUser.SSOProvider
avatar = fullUser.AvatarURL
if avatar == "" {
avatar = "https://ui-avatars.com/api/?name=" + url.QueryEscape(displayName) + "&background=random"
}
}

writeJSON(w, http.StatusOK, map[string]any{
"message": "logged in",
"user": map[string]any{
"username": user.Username,
"id": user.ID,
"username": user.Username,
"id": user.ID,
"role": user.Role,
"displayName": displayName,
"email": email,
"timezone": timezone,
"ssoProvider": ssoProvider,
"avatar": avatar,
},
})
}
Expand All @@ -129,7 +156,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
}

// Clear Cookie
http.SetCookie(w, &http.Cookie{
http.SetCookie(w, &http.Cookie{ // #nosec G124 -- Secure defaults true; configurable for local HTTP dev
Name: "auth_token",
Value: "",
Expires: time.Now().Add(-1 * time.Hour),
Expand Down Expand Up @@ -183,6 +210,7 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
"ssoProvider": user.SSOProvider,
"avatar": avatar,
"displayName": displayName,
"role": user.Role,
},
})
}
Expand Down Expand Up @@ -247,25 +275,45 @@ func (h *AuthHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "settings updated"})
}

// IsAuthenticated checks whether a request has a valid session cookie or API key
// without writing a response. Used by handlers that need optional auth checks.
func (h *AuthHandler) IsAuthenticated(r *http.Request) bool {
// AuthInfo holds identity information extracted from a request.
type AuthInfo struct {
Authenticated bool
UserID int64
Role string
}

// GetAuthInfo extracts authentication details from a request without writing a response.
// Returns user ID, role, and whether the request is authenticated.
func (h *AuthHandler) GetAuthInfo(r *http.Request) AuthInfo {
// Check Bearer token
authHeader := r.Header.Get("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
valid, err := h.store.ValidateAPIKey(token)
valid, role, err := h.store.ValidateAPIKey(token)
if err == nil && valid {
return true
return AuthInfo{Authenticated: true, UserID: APIKeyUserID, Role: role}
}
}
// Check session cookie
c, err := r.Cookie("auth_token")
if err != nil {
return false
return AuthInfo{}
}
sess, err := h.store.GetSession(c.Value)
return err == nil && sess != nil
if err != nil || sess == nil {
return AuthInfo{}
}
role, err := h.store.GetUserRole(sess.UserID)
if err != nil {
return AuthInfo{}
}
return AuthInfo{Authenticated: true, UserID: sess.UserID, Role: role}
}

// IsAuthenticated checks whether a request has a valid session cookie or API key
// without writing a response. Used by handlers that need optional auth checks.
func (h *AuthHandler) IsAuthenticated(r *http.Request) bool {
return h.GetAuthInfo(r).Authenticated
}

// Middleware
Expand All @@ -276,11 +324,12 @@ func (h *AuthHandler) AuthMiddleware(next http.Handler) http.Handler {
authHeader := r.Header.Get("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
valid, err := h.store.ValidateAPIKey(token)
valid, role, err := h.store.ValidateAPIKey(token)
if err == nil && valid {
// Valid API Key - use special negative ID to distinguish from real users
// SECURITY: APIKeyUserID (-1) prevents confusion with real user IDs
ctx := context.WithValue(r.Context(), contextKeyUserID, APIKeyUserID)
ctx = context.WithValue(ctx, contextKeyUserRole, role)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
Expand All @@ -301,8 +350,16 @@ func (h *AuthHandler) AuthMiddleware(next http.Handler) http.Handler {
return
}

// 4. Inject UserID into Context
// 4. Fetch user role
role, err := h.store.GetUserRole(sess.UserID)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

// 5. Inject UserID and Role into Context
ctx := context.WithValue(r.Context(), contextKeyUserID, sess.UserID)
ctx = context.WithValue(ctx, contextKeyUserRole, role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
20 changes: 19 additions & 1 deletion internal/api/handlers_apikeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ func NewAPIKeyHandler(store *db.Store) *APIKeyHandler {
// @Success 200 {object} object{keys=[]db.APIKey}
// @Router /api-keys [get]
func (h *APIKeyHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleAdmin) {
return
}
keys, err := h.store.ListAPIKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list keys")
Expand All @@ -44,8 +47,13 @@ func (h *APIKeyHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
// @Failure 400 {object} object{error=string} "Name is required"
// @Router /api-keys [post]
func (h *APIKeyHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleAdmin) {
return
}

var req struct {
Name string `json:"name"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
Expand All @@ -55,8 +63,15 @@ func (h *APIKeyHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.Role != "" && !ValidRole(req.Role) {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if req.Role == "" {
req.Role = RoleEditor
}

rawKey, err := h.store.CreateAPIKey(req.Name)
rawKey, err := h.store.CreateAPIKey(req.Name, req.Role)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create key")
return
Expand All @@ -79,6 +94,9 @@ func (h *APIKeyHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
// @Failure 400 {object} object{error=string} "Invalid ID"
// @Router /api-keys/{id} [delete]
func (h *APIKeyHandler) DeleteKey(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleAdmin) {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions internal/api/handlers_apikeys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
Expand All @@ -16,6 +17,7 @@ func TestAPIKeysHandler(t *testing.T) {

// List Empty
req := httptest.NewRequest("GET", "/api/api-keys", nil)
req = req.WithContext(context.WithValue(req.Context(), contextKeyUserRole, RoleAdmin))
w := httptest.NewRecorder()
h.ListKeys(w, req)
if w.Code != http.StatusOK {
Expand All @@ -26,6 +28,7 @@ func TestAPIKeysHandler(t *testing.T) {
payload := map[string]string{"name": "TestKey"}
body, _ := json.Marshal(payload)
req = httptest.NewRequest("POST", "/api/api-keys", bytes.NewBuffer(body))
req = req.WithContext(context.WithValue(req.Context(), contextKeyUserRole, RoleAdmin))
w = httptest.NewRecorder()
h.CreateKey(w, req)

Expand Down
4 changes: 2 additions & 2 deletions internal/api/handlers_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func TestAuthLogin(t *testing.T) {
_, _, authH, _, s := setupTest(t)

// Setup User
if err := s.CreateUser("admin", "correct-password", "UTC"); err != nil {
if err := s.CreateUser("admin", "correct-password", "UTC", "admin"); err != nil {
t.Fatalf("Failed to create user: %v", err)
}

Expand Down Expand Up @@ -79,7 +79,7 @@ func TestAuthMeIntegration(t *testing.T) {
_, _, _, router, s := setupTest(t)

// Setup User
if err := s.CreateUser("admin", "correct-password", "UTC"); err != nil {
if err := s.CreateUser("admin", "correct-password", "UTC", "admin"); err != nil {
t.Fatalf("Failed to create user: %v", err)
}

Expand Down
24 changes: 24 additions & 0 deletions internal/api/handlers_crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const maxNameLength = 255
// @Failure 409 {object} object{error=string} "Group already exists"
// @Router /groups [post]
func (h *CRUDHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
var req struct {
Name string `json:"name"`
}
Expand Down Expand Up @@ -116,6 +119,9 @@ func (h *CRUDHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
// @Failure 400 {string} string "ID required"
// @Router /groups/{id} [delete]
func (h *CRUDHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "ID required", http.StatusBadRequest)
Expand All @@ -141,6 +147,9 @@ func (h *CRUDHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
// @Failure 400 {string} string "Name is required"
// @Router /groups/{id} [put]
func (h *CRUDHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "ID required", http.StatusBadRequest)
Expand Down Expand Up @@ -188,6 +197,9 @@ func (h *CRUDHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
// @Failure 409 {string} string "Monitor name already exists"
// @Router /monitors [post]
func (h *CRUDHandler) CreateMonitor(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
var req struct {
Name string `json:"name"`
URL string `json:"url"`
Expand Down Expand Up @@ -349,6 +361,9 @@ func (h *CRUDHandler) GetGroups(w http.ResponseWriter, r *http.Request) {
// @Failure 400 {string} string "ID required"
// @Router /monitors/{id} [put]
func (h *CRUDHandler) UpdateMonitor(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "ID required", http.StatusBadRequest)
Expand Down Expand Up @@ -407,6 +422,9 @@ func (h *CRUDHandler) UpdateMonitor(w http.ResponseWriter, r *http.Request) {
// @Failure 400 {string} string "ID required"
// @Router /monitors/{id} [delete]
func (h *CRUDHandler) DeleteMonitor(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "ID required", http.StatusBadRequest)
Expand All @@ -432,6 +450,9 @@ func (h *CRUDHandler) DeleteMonitor(w http.ResponseWriter, r *http.Request) {
// @Failure 404 {object} object{error=string} "Monitor not found"
// @Router /monitors/{id}/pause [post]
func (h *CRUDHandler) PauseMonitor(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "ID required")
Expand Down Expand Up @@ -462,6 +483,9 @@ func (h *CRUDHandler) PauseMonitor(w http.ResponseWriter, r *http.Request) {
// @Failure 404 {object} object{error=string} "Monitor not found"
// @Router /monitors/{id}/resume [post]
func (h *CRUDHandler) ResumeMonitor(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "ID required")
Expand Down
18 changes: 18 additions & 0 deletions internal/api/handlers_incidents.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ func incidentToDTO(i db.Incident, updates []db.IncidentUpdate) IncidentResponseD
// @Failure 400 {string} string "Invalid request body"
// @Router /incidents [post]
func (h *IncidentHandler) CreateIncident(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
var req struct {
Title string `json:"title"`
Description string `json:"description"`
Expand Down Expand Up @@ -211,6 +214,9 @@ func (h *IncidentHandler) GetIncident(w http.ResponseWriter, r *http.Request) {
// @Failure 404 {string} string "Incident not found"
// @Router /incidents/{id} [put]
func (h *IncidentHandler) UpdateIncident(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")

existing, err := h.store.GetIncidentByID(id)
Expand Down Expand Up @@ -293,6 +299,9 @@ func (h *IncidentHandler) UpdateIncident(w http.ResponseWriter, r *http.Request)
// @Failure 500 {string} string "Failed to delete incident"
// @Router /incidents/{id} [delete]
func (h *IncidentHandler) DeleteIncident(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")

if err := h.store.DeleteIncident(id); err != nil {
Expand All @@ -317,6 +326,9 @@ func (h *IncidentHandler) DeleteIncident(w http.ResponseWriter, r *http.Request)
// @Failure 404 {string} string "Outage not found"
// @Router /outages/{id}/promote [post]
func (h *IncidentHandler) PromoteOutage(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
idStr := chi.URLParam(r, "id")
outageID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
Expand Down Expand Up @@ -413,6 +425,9 @@ func (h *IncidentHandler) PromoteOutage(w http.ResponseWriter, r *http.Request)
// @Failure 404 {string} string "Incident not found"
// @Router /incidents/{id}/visibility [patch]
func (h *IncidentHandler) SetVisibility(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")

incident, err := h.store.GetIncidentByID(id)
Expand Down Expand Up @@ -461,6 +476,9 @@ func (h *IncidentHandler) SetVisibility(w http.ResponseWriter, r *http.Request)
// @Failure 404 {string} string "Incident not found"
// @Router /incidents/{id}/updates [post]
func (h *IncidentHandler) AddUpdate(w http.ResponseWriter, r *http.Request) {
if !requireRole(w, r, RoleEditor) {
return
}
id := chi.URLParam(r, "id")

incident, err := h.store.GetIncidentByID(id)
Expand Down
Loading
Loading