Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type authLogsResponse struct {
|
||||
Events []settings.AuthAuditEvent `json:"events"`
|
||||
}
|
||||
|
||||
func (app *App) authLogsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeLogsRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadAuthAuditOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]settings.AuthAuditEvent, 0, len(store.Events))
|
||||
for index := len(store.Events) - 1; index >= 0; index-- {
|
||||
filtered = append(filtered, store.Events[index])
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, authLogsResponse{Events: filtered})
|
||||
}
|
||||
|
||||
func appendAuthAudit(
|
||||
configDir string,
|
||||
action string,
|
||||
outcome settings.AuthAuditOutcome,
|
||||
category settings.AuthAuditCategory,
|
||||
actor string,
|
||||
target string,
|
||||
detail string,
|
||||
) {
|
||||
eventID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = settings.AppendAuthAuditEvent(configDir, settings.AuthAuditEvent{
|
||||
ID: eventID,
|
||||
At: time.Now().UTC(),
|
||||
Action: action,
|
||||
Outcome: outcome,
|
||||
Category: category,
|
||||
Actor: actor,
|
||||
Target: target,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
func actorUsernameFromRequest(request *http.Request) string {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return user.Username
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestAuthLogsRequiresLogsRead(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
for index := range payload.Groups {
|
||||
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
// Strip logs.read and the permissions that would trigger migration.
|
||||
filtered := make([]string, 0)
|
||||
for _, permission := range payload.Groups[index].Permissions {
|
||||
switch permission {
|
||||
case "logs.read", "nodes.read", "users.manage":
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, permission)
|
||||
}
|
||||
payload.Groups[index].Permissions = filtered
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogsAllowsLogsReadWithoutUsersManage(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
for index := range payload.Groups {
|
||||
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
payload.Groups[index].Permissions = []string{"logs.read"}
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogsRecordsLoginAndUserCreate(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
_ = seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
failBody := []byte(`{"username":"Admin","password":"wrong password that fails"}`)
|
||||
failReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(failBody))
|
||||
failRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(failRec, failReq)
|
||||
if failRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("failed login status %d body %s", failRec.Code, failRec.Body.String())
|
||||
}
|
||||
|
||||
okBody := []byte(`{"username":"Admin","password":"correct horse battery staple extra"}`)
|
||||
okReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(okBody))
|
||||
okRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(okRec, okReq)
|
||||
if okRec.Code != http.StatusOK {
|
||||
t.Fatalf("login status %d body %s", okRec.Code, okRec.Body.String())
|
||||
}
|
||||
var loginCookie *http.Cookie
|
||||
for _, candidate := range okRec.Result().Cookies() {
|
||||
if candidate.Name == auth.SessionCookieName {
|
||||
loginCookie = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if loginCookie == nil {
|
||||
t.Fatal("login did not set session cookie")
|
||||
}
|
||||
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/users",
|
||||
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":[]}`),
|
||||
),
|
||||
loginCookie,
|
||||
)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create user status %d body %s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
logsReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), loginCookie)
|
||||
logsRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(logsRec, logsReq)
|
||||
if logsRec.Code != http.StatusOK {
|
||||
t.Fatalf("auth-logs status %d body %s", logsRec.Code, logsRec.Body.String())
|
||||
}
|
||||
|
||||
var logs authLogsResponse
|
||||
if err := json.Unmarshal(logsRec.Body.Bytes(), &logs); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(logs.Events) < 3 {
|
||||
t.Fatalf("expected at least 3 events, got %d", len(logs.Events))
|
||||
}
|
||||
|
||||
var sawLoginFailure, sawLoginSuccess, sawUserCreate bool
|
||||
for _, event := range logs.Events {
|
||||
switch {
|
||||
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeFailure:
|
||||
sawLoginFailure = true
|
||||
if event.Detail != "invalid credentials" {
|
||||
t.Fatalf("failure detail = %q", event.Detail)
|
||||
}
|
||||
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeSuccess:
|
||||
sawLoginSuccess = true
|
||||
if event.Actor != "Admin" {
|
||||
t.Fatalf("login actor = %q", event.Actor)
|
||||
}
|
||||
case event.Action == "user_create" && event.Target == "alice":
|
||||
sawUserCreate = true
|
||||
if event.Category != settings.AuthAuditCategoryUser {
|
||||
t.Fatalf("category = %q", event.Category)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawLoginFailure || !sawLoginSuccess || !sawUserCreate {
|
||||
t.Fatalf(
|
||||
"missing events: failure=%v success=%v create=%v events=%+v",
|
||||
sawLoginFailure,
|
||||
sawLoginSuccess,
|
||||
sawUserCreate,
|
||||
logs.Events,
|
||||
)
|
||||
}
|
||||
|
||||
// Newest first: user_create should appear before older login events.
|
||||
if logs.Events[0].Action != "user_create" {
|
||||
t.Fatalf("expected newest event user_create, got %q", logs.Events[0].Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLogsReadPermission(t *testing.T) {
|
||||
payload := settings.Settings{
|
||||
Groups: []settings.Group{
|
||||
{Name: "ops", Permissions: []string{"nodes.read", "jobs.read"}},
|
||||
{Name: "hr", Permissions: []string{"users.manage"}},
|
||||
{Name: "auditors", Permissions: []string{"logs.read"}},
|
||||
{Name: "viewers", Permissions: []string{"jobs.read"}},
|
||||
},
|
||||
}
|
||||
if !migrateLogsReadPermission(&payload) {
|
||||
t.Fatal("expected migration to change settings")
|
||||
}
|
||||
if !containsPermission(payload.Groups[0].Permissions, "logs.read") {
|
||||
t.Fatal("ops should gain logs.read from nodes.read")
|
||||
}
|
||||
if !containsPermission(payload.Groups[1].Permissions, "logs.read") {
|
||||
t.Fatal("hr should gain logs.read from users.manage")
|
||||
}
|
||||
if len(payload.Groups[2].Permissions) != 1 {
|
||||
t.Fatal("auditors should be unchanged")
|
||||
}
|
||||
if containsPermission(payload.Groups[3].Permissions, "logs.read") {
|
||||
t.Fatal("viewers should not gain logs.read")
|
||||
}
|
||||
if migrateLogsReadPermission(&payload) {
|
||||
t.Fatal("second migration should be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func containsPermission(permissions []string, wanted string) bool {
|
||||
for _, permission := range permissions {
|
||||
if permission == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required")
|
||||
var errActionsCreateRequired = errors.New("permission actions.create required")
|
||||
var errActionsUpdateRequired = errors.New("permission actions.update required")
|
||||
var errActionsDeleteRequired = errors.New("permission actions.delete required")
|
||||
var errLogsReadRequired = errors.New("permission logs.read required")
|
||||
|
||||
// permissionsForUser returns the union of permissions from the user's groups.
|
||||
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
|
||||
@@ -100,6 +101,22 @@ func (app *App) authorizeNodesRead(request *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) authorizeLogsRead(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
payload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !userHasPermission(user, payload.Groups, "logs.read") {
|
||||
return errLogsReadRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) authorizeNodesExec(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -34,6 +34,7 @@ var allowedPermissions = map[string]struct{}{
|
||||
"users.manage": {},
|
||||
"secrets.manage": {},
|
||||
"roles.manage": {},
|
||||
"logs.read": {},
|
||||
}
|
||||
|
||||
func groupsGetHandler(configDir string) http.HandlerFunc {
|
||||
@@ -89,6 +90,19 @@ func groupsUpsertHandler(configDir string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
action := "group_create"
|
||||
if updated {
|
||||
action = "group_update"
|
||||
}
|
||||
appendAuthAudit(
|
||||
configDir,
|
||||
action,
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategoryGroup,
|
||||
actorUsernameFromRequest(request),
|
||||
payload.Group.Name,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
|
||||
}
|
||||
}
|
||||
@@ -137,6 +151,15 @@ func groupsDeleteHandler(configDir string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
appendAuthAudit(
|
||||
configDir,
|
||||
"group_delete",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategoryGroup,
|
||||
actorUsernameFromRequest(request),
|
||||
name,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,15 @@ func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Requ
|
||||
_ = app.Sessions.DestroySession(writer, request)
|
||||
}
|
||||
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"password_change",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategorySelf,
|
||||
user.Username,
|
||||
user.Username,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
@@ -257,6 +266,15 @@ func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.R
|
||||
return
|
||||
}
|
||||
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"totp_enable",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategorySelf,
|
||||
user.Username,
|
||||
user.Username,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
@@ -319,5 +337,14 @@ func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.R
|
||||
return
|
||||
}
|
||||
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"totp_disable",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategorySelf,
|
||||
user.Username,
|
||||
user.Username,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
const sessionIdleExemptHeader = "X-Session-Idle-Exempt"
|
||||
|
||||
func (app *App) withMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
path := request.URL.Path
|
||||
@@ -75,7 +77,8 @@ func (app *App) withMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
security := effectiveSecurity(settingsPayload.Security)
|
||||
session, err := app.Sessions.LookupValidSession(writer, request, security)
|
||||
touchActivity := request.Header.Get(sessionIdleExemptHeader) != "1"
|
||||
session, err := app.Sessions.LookupValidSession(writer, request, security, touchActivity)
|
||||
if err != nil {
|
||||
errorMessage := "authentication required"
|
||||
if errors.Is(err, auth.ErrSessionIdle) {
|
||||
@@ -142,7 +145,7 @@ func (app *App) withCORS(next http.Handler) http.Handler {
|
||||
writer.Header().Set("Vary", "Origin")
|
||||
}
|
||||
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, "+sessionIdleExemptHeader)
|
||||
|
||||
if request.Method == http.MethodOptions {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -81,3 +81,129 @@ func TestMiddlewareReturnsAuthenticationRequiredWithoutCookie(t *testing.T) {
|
||||
t.Fatalf("expected authentication required, got %q", payload.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareIdleExemptDoesNotAdvanceLastSeenAt(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
keyBytes := make([]byte, 32)
|
||||
for index := range keyBytes {
|
||||
keyBytes[index] = byte(index + 1)
|
||||
}
|
||||
|
||||
store, err := settings.LoadSessions(configDir, keyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
|
||||
store.Sessions[0].LastSeenAt = originalLastSeen
|
||||
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
|
||||
request.Header.Set(sessionIdleExemptHeader, "1")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
store, err = settings.LoadSessions(configDir, keyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions after request: %v", err)
|
||||
}
|
||||
if len(store.Sessions) != 1 {
|
||||
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
|
||||
}
|
||||
if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) {
|
||||
t.Fatalf(
|
||||
"expected LastSeenAt %v unchanged, got %v",
|
||||
originalLastSeen,
|
||||
store.Sessions[0].LastSeenAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareIdleExemptStillReturnsSessionIdle(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
keyBytes := make([]byte, 32)
|
||||
for index := range keyBytes {
|
||||
keyBytes[index] = byte(index + 1)
|
||||
}
|
||||
|
||||
store, err := settings.LoadSessions(configDir, keyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-31 * time.Minute)
|
||||
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
|
||||
request.Header.Set(sessionIdleExemptHeader, "1")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
var payload apiErrorResponse
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Error != "session_idle" {
|
||||
t.Fatalf("expected session_idle, got %q", payload.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareNormalRequestAdvancesLastSeenAt(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
keyBytes := make([]byte, 32)
|
||||
for index := range keyBytes {
|
||||
keyBytes[index] = byte(index + 1)
|
||||
}
|
||||
|
||||
store, err := settings.LoadSessions(configDir, keyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
|
||||
store.Sessions[0].LastSeenAt = originalLastSeen
|
||||
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
store, err = settings.LoadSessions(configDir, keyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions after request: %v", err)
|
||||
}
|
||||
if len(store.Sessions) != 1 {
|
||||
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
|
||||
}
|
||||
if !store.Sessions[0].LastSeenAt.After(originalLastSeen) {
|
||||
t.Fatalf(
|
||||
"expected LastSeenAt after %v, got %v",
|
||||
originalLastSeen,
|
||||
store.Sessions[0].LastSeenAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,50 +450,24 @@ func (app *App) nodesDeleteHandler(writer http.ResponseWriter, request *http.Req
|
||||
}
|
||||
|
||||
func (app *App) nodeLogsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeNodesRead(request); err != nil {
|
||||
if err := app.authorizeLogsRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeAuditOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
nodesStore, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
nodeByID := make(map[string]settings.Node, len(nodesStore.Nodes))
|
||||
for _, node := range nodesStore.Nodes {
|
||||
nodeByID[node.ID] = node
|
||||
}
|
||||
|
||||
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
|
||||
isAdmin := userIsAdministrator(user)
|
||||
filtered := make([]settings.NodeAuditEvent, 0, len(store.Events))
|
||||
for index := len(store.Events) - 1; index >= 0; index-- {
|
||||
event := store.Events[index]
|
||||
if kindFilter != "" && string(event.NodeKind) != kindFilter {
|
||||
continue
|
||||
}
|
||||
if !userCanSeeNodeAuditEvent(user, settingsPayload.Groups, nodeByID, event, isAdmin) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
|
||||
@@ -609,25 +583,6 @@ func userCanAccessNode(user settings.UserCredential, groups []settings.Group, no
|
||||
return false
|
||||
}
|
||||
|
||||
func userCanSeeNodeAuditEvent(
|
||||
user settings.UserCredential,
|
||||
groups []settings.Group,
|
||||
nodeByID map[string]settings.Node,
|
||||
event settings.NodeAuditEvent,
|
||||
isAdmin bool,
|
||||
) bool {
|
||||
if isAdmin {
|
||||
return true
|
||||
}
|
||||
if event.Actor == user.Username {
|
||||
return true
|
||||
}
|
||||
if node, ok := nodeByID[event.NodeID]; ok {
|
||||
return userCanAccessNode(user, groups, node)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendNodeAudit(
|
||||
configDir string,
|
||||
action settings.NodeAuditAction,
|
||||
|
||||
@@ -644,3 +644,84 @@ func TestNodeLogsListFiltersByKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeLogsRequiresLogsRead(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
for index := range payload.Groups {
|
||||
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
filtered := make([]string, 0)
|
||||
for _, permission := range payload.Groups[index].Permissions {
|
||||
switch permission {
|
||||
case "logs.read", "nodes.read", "users.manage":
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, permission)
|
||||
}
|
||||
payload.Groups[index].Permissions = filtered
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeLogsAllowsLogsReadWithoutNodesRead(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
for index := range payload.Groups {
|
||||
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
payload.Groups[index].Permissions = []string{"logs.read"}
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
if err := settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
|
||||
ID: "evt-1",
|
||||
At: time.Now().UTC(),
|
||||
Action: settings.NodeAuditActionCreate,
|
||||
Actor: "Admin",
|
||||
NodeID: "node-1",
|
||||
NodeName: "test-node",
|
||||
NodeKind: settings.NodeKindContainer,
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendNodeAuditEvent: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var logs nodeLogsResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &logs); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(logs.Events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(logs.Events))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func NewRouter(configDir string) http.Handler {
|
||||
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
||||
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
|
||||
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
|
||||
|
||||
return app.withCORS(app.withMiddleware(mux))
|
||||
}
|
||||
|
||||
@@ -360,13 +360,32 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
|
||||
break
|
||||
}
|
||||
}
|
||||
attemptedUsername := strings.TrimSpace(payload.Username)
|
||||
if matched == nil || !matched.Enabled {
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"login",
|
||||
settings.AuthAuditOutcomeFailure,
|
||||
settings.AuthAuditCategoryAuth,
|
||||
attemptedUsername,
|
||||
attemptedUsername,
|
||||
"invalid credentials",
|
||||
)
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"login",
|
||||
settings.AuthAuditOutcomeFailure,
|
||||
settings.AuthAuditCategoryAuth,
|
||||
attemptedUsername,
|
||||
matched.Username,
|
||||
"invalid credentials",
|
||||
)
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
@@ -381,6 +400,15 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
|
||||
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
|
||||
if requiresTOTP {
|
||||
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"login",
|
||||
settings.AuthAuditOutcomeFailure,
|
||||
settings.AuthAuditCategoryAuth,
|
||||
matched.Username,
|
||||
matched.Username,
|
||||
"invalid or missing TOTP code",
|
||||
)
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
|
||||
return
|
||||
}
|
||||
@@ -391,13 +419,34 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"login",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategoryAuth,
|
||||
matched.Username,
|
||||
matched.Username,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
|
||||
}
|
||||
|
||||
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
actor := actorUsernameFromRequest(request)
|
||||
if app.Sessions != nil {
|
||||
_ = app.Sessions.DestroySession(writer, request)
|
||||
}
|
||||
if actor != "" {
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"logout",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategoryAuth,
|
||||
actor,
|
||||
actor,
|
||||
"",
|
||||
)
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
@@ -459,6 +508,7 @@ func allPermissionList() []string {
|
||||
"users.manage",
|
||||
"secrets.manage",
|
||||
"roles.manage",
|
||||
"logs.read",
|
||||
}
|
||||
_ = list
|
||||
return ordered
|
||||
@@ -484,5 +534,35 @@ func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
||||
payload.Groups = []settings.Group{}
|
||||
}
|
||||
payload.Network = settings.EffectiveNetwork(payload.Network)
|
||||
if migrateLogsReadPermission(&payload) {
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
return settings.Settings{}, err
|
||||
}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// migrateLogsReadPermission grants logs.read to groups that previously could
|
||||
// view logs via nodes.read or users.manage. Returns true when settings changed.
|
||||
func migrateLogsReadPermission(payload *settings.Settings) bool {
|
||||
changed := false
|
||||
for index := range payload.Groups {
|
||||
group := &payload.Groups[index]
|
||||
hasLogsRead := false
|
||||
hadLogAccess := false
|
||||
for _, permission := range group.Permissions {
|
||||
switch permission {
|
||||
case "logs.read":
|
||||
hasLogsRead = true
|
||||
case "nodes.read", "users.manage":
|
||||
hadLogAccess = true
|
||||
}
|
||||
}
|
||||
if hasLogsRead || !hadLogAccess {
|
||||
continue
|
||||
}
|
||||
group.Permissions = append(group.Permissions, "logs.read")
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -137,6 +137,15 @@ func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"user_create",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategoryUser,
|
||||
actorUsernameFromRequest(request),
|
||||
username,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
@@ -254,6 +263,15 @@ func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Requ
|
||||
_ = app.Sessions.InvalidateUserSessions(userID)
|
||||
}
|
||||
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"user_update",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategoryUser,
|
||||
actorUsernameFromRequest(request),
|
||||
target.Username,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
@@ -293,12 +311,14 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
|
||||
adminCount := countAdministrators(store.Users)
|
||||
remaining := make([]settings.UserCredential, 0, len(store.Users))
|
||||
found := false
|
||||
deletedUsername := ""
|
||||
for _, user := range store.Users {
|
||||
if user.ID != userID {
|
||||
remaining = append(remaining, user)
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
deletedUsername = user.Username
|
||||
if userIsAdministrator(user) && adminCount <= 1 {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{
|
||||
Error: "cannot delete the last administrator",
|
||||
@@ -322,6 +342,15 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
|
||||
_ = app.Sessions.InvalidateUserSessions(userID)
|
||||
}
|
||||
|
||||
appendAuthAudit(
|
||||
app.ConfigDir,
|
||||
"user_delete",
|
||||
settings.AuthAuditOutcomeSuccess,
|
||||
settings.AuthAuditCategoryUser,
|
||||
actorUsernameFromRequest(request),
|
||||
deletedUsername,
|
||||
"",
|
||||
)
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
|
||||
@@ -87,11 +87,15 @@ func (manager *SessionManager) CreateSession(
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// LookupValidSession finds a non-expired, non-idle session and may rotate it.
|
||||
// LookupValidSession finds a non-expired, non-idle session.
|
||||
// When touchActivity is true, LastSeenAt is updated and the session may rotate.
|
||||
// When touchActivity is false, the session is validated only (no LastSeenAt update,
|
||||
// rotation, or sessions file write unless the session is being destroyed as idle/expired).
|
||||
func (manager *SessionManager) LookupValidSession(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
security settings.SecuritySettings,
|
||||
touchActivity bool,
|
||||
) (settings.SessionRecord, error) {
|
||||
cookie, err := request.Cookie(SessionCookieName)
|
||||
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
||||
@@ -140,6 +144,10 @@ func (manager *SessionManager) LookupValidSession(
|
||||
return settings.SessionRecord{}, ErrSessionNotFound
|
||||
}
|
||||
|
||||
if !touchActivity {
|
||||
return *found, nil
|
||||
}
|
||||
|
||||
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
|
||||
now.Sub(found.CreatedAt) >= lifetime/2
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestLookupValidSessionReturnsIdle(t *testing.T) {
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
_, err = manager.LookupValidSession(lookupRec, request, security)
|
||||
_, err = manager.LookupValidSession(lookupRec, request, security, true)
|
||||
if !errors.Is(err, ErrSessionIdle) {
|
||||
t.Fatalf("expected ErrSessionIdle, got %v", err)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestLookupValidSessionMissingCookie(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
_, err := manager.LookupValidSession(lookupRec, request, security)
|
||||
_, err := manager.LookupValidSession(lookupRec, request, security, true)
|
||||
if !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("expected ErrSessionNotFound, got %v", err)
|
||||
}
|
||||
@@ -76,12 +76,95 @@ func TestLookupValidSessionUnknownCookie(t *testing.T) {
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"})
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
_, err := manager.LookupValidSession(lookupRec, request, security)
|
||||
_, err := manager.LookupValidSession(lookupRec, request, security, true)
|
||||
if !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("expected ErrSessionNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupValidSessionWithoutTouchLeavesLastSeenAt(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
key := testSessionKey()
|
||||
manager := NewSessionManager(configDir, key)
|
||||
security := settings.DefaultSecuritySettings()
|
||||
security.IdleTimeoutMinutes = 30
|
||||
|
||||
createRec := httptest.NewRecorder()
|
||||
record, err := manager.CreateSession(createRec, "user-1", security)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
store, err := settings.LoadSessions(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
|
||||
store.Sessions[0].LastSeenAt = originalLastSeen
|
||||
if err := settings.SaveSessions(configDir, store, key); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
got, err := manager.LookupValidSession(lookupRec, request, security, false)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupValidSession: %v", err)
|
||||
}
|
||||
if got.ID != record.ID {
|
||||
t.Fatalf("expected session id %q, got %q", record.ID, got.ID)
|
||||
}
|
||||
|
||||
store, err = settings.LoadSessions(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions after lookup: %v", err)
|
||||
}
|
||||
if len(store.Sessions) != 1 {
|
||||
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
|
||||
}
|
||||
if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) {
|
||||
t.Fatalf(
|
||||
"expected LastSeenAt %v unchanged, got %v",
|
||||
originalLastSeen,
|
||||
store.Sessions[0].LastSeenAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupValidSessionIdleWithoutTouchStillIdle(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
key := testSessionKey()
|
||||
manager := NewSessionManager(configDir, key)
|
||||
security := settings.DefaultSecuritySettings()
|
||||
security.IdleTimeoutMinutes = 5
|
||||
|
||||
createRec := httptest.NewRecorder()
|
||||
record, err := manager.CreateSession(createRec, "user-1", security)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
store, err := settings.LoadSessions(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
|
||||
if err := settings.SaveSessions(configDir, store, key); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
_, err = manager.LookupValidSession(lookupRec, request, security, false)
|
||||
if !errors.Is(err, ErrSessionIdle) {
|
||||
t.Fatalf("expected ErrSessionIdle, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testSessionKey() []byte {
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// MaxAuthAuditEvents is the retention cap for auth-audit.json.
|
||||
const MaxAuthAuditEvents = 1000
|
||||
|
||||
// LoadAuthAudit reads and parses auth-audit.json from dir.
|
||||
func LoadAuthAudit(dir string) (AuthAuditStore, error) {
|
||||
path := filepath.Join(dir, AuthAuditFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return AuthAuditStore{}, err
|
||||
}
|
||||
|
||||
var store AuthAuditStore
|
||||
if err := json.Unmarshal(payload, &store); err != nil {
|
||||
return AuthAuditStore{}, fmt.Errorf("parse auth audit: %w", err)
|
||||
}
|
||||
if store.Events == nil {
|
||||
store.Events = []AuthAuditEvent{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadAuthAuditOrEmpty returns an empty store when auth-audit.json is missing.
|
||||
func LoadAuthAuditOrEmpty(dir string) (AuthAuditStore, error) {
|
||||
store, err := LoadAuthAudit(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return AuthAuditStore{Events: []AuthAuditEvent{}}, nil
|
||||
}
|
||||
return AuthAuditStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveAuthAudit writes auth-audit.json to dir with mode 0640.
|
||||
func SaveAuthAudit(dir string, store AuthAuditStore) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if store.Events == nil {
|
||||
store.Events = []AuthAuditEvent{}
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode auth audit: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, AuthAuditFileName)
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write auth audit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendAuthAuditEvent appends event and trims to MaxAuthAuditEvents (newest kept).
|
||||
func AppendAuthAuditEvent(dir string, event AuthAuditEvent) error {
|
||||
store, err := LoadAuthAuditOrEmpty(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.Events = append(store.Events, event)
|
||||
if len(store.Events) > MaxAuthAuditEvents {
|
||||
store.Events = store.Events[len(store.Events)-MaxAuthAuditEvents:]
|
||||
}
|
||||
return SaveAuthAudit(dir, store)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthAuditRoundTripAndCap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
store, err := LoadAuthAuditOrEmpty(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAuthAuditOrEmpty: %v", err)
|
||||
}
|
||||
if len(store.Events) != 0 {
|
||||
t.Fatalf("expected empty, got %d", len(store.Events))
|
||||
}
|
||||
|
||||
event := AuthAuditEvent{
|
||||
ID: "evt-1",
|
||||
At: time.Now().UTC(),
|
||||
Action: "login",
|
||||
Outcome: AuthAuditOutcomeSuccess,
|
||||
Category: AuthAuditCategoryAuth,
|
||||
Actor: "admin",
|
||||
}
|
||||
if err := AppendAuthAuditEvent(dir, event); err != nil {
|
||||
t.Fatalf("AppendAuthAuditEvent: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadAuthAudit(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAuthAudit: %v", err)
|
||||
}
|
||||
if len(loaded.Events) != 1 || loaded.Events[0].Action != "login" {
|
||||
t.Fatalf("loaded = %+v", loaded)
|
||||
}
|
||||
if loaded.Events[0].Outcome != AuthAuditOutcomeSuccess {
|
||||
t.Fatalf("outcome = %q", loaded.Events[0].Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendAuthAuditEventTrimsToCap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
originalCap := MaxAuthAuditEvents
|
||||
store := AuthAuditStore{Events: make([]AuthAuditEvent, 0, MaxAuthAuditEvents+5)}
|
||||
for index := 0; index < MaxAuthAuditEvents+5; index++ {
|
||||
store.Events = append(store.Events, AuthAuditEvent{
|
||||
ID: "evt",
|
||||
At: time.Now().UTC(),
|
||||
Action: "login",
|
||||
Outcome: AuthAuditOutcomeSuccess,
|
||||
Category: AuthAuditCategoryAuth,
|
||||
Actor: "admin",
|
||||
Detail: string(rune('a' + (index % 26))),
|
||||
})
|
||||
}
|
||||
if err := SaveAuthAudit(dir, store); err != nil {
|
||||
t.Fatalf("SaveAuthAudit: %v", err)
|
||||
}
|
||||
if err := AppendAuthAuditEvent(dir, AuthAuditEvent{
|
||||
ID: "newest",
|
||||
At: time.Now().UTC(),
|
||||
Action: "logout",
|
||||
Outcome: AuthAuditOutcomeSuccess,
|
||||
Category: AuthAuditCategoryAuth,
|
||||
Actor: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendAuthAuditEvent: %v", err)
|
||||
}
|
||||
loaded, err := LoadAuthAudit(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAuthAudit: %v", err)
|
||||
}
|
||||
if len(loaded.Events) != originalCap {
|
||||
t.Fatalf("len = %d, want %d", len(loaded.Events), originalCap)
|
||||
}
|
||||
if loaded.Events[len(loaded.Events)-1].ID != "newest" {
|
||||
t.Fatalf("last id = %q", loaded.Events[len(loaded.Events)-1].ID)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
NodesFileName = "nodes.json"
|
||||
NodeKeysFileName = "node-keys.enc"
|
||||
NodeAuditFileName = "node-audit.json"
|
||||
AuthAuditFileName = "auth-audit.json"
|
||||
ActionsFileName = "actions.json"
|
||||
)
|
||||
|
||||
|
||||
@@ -186,6 +186,41 @@ type NodeAuditStore struct {
|
||||
Events []NodeAuditEvent `json:"events"`
|
||||
}
|
||||
|
||||
// AuthAuditOutcome is success or failure for an auth/user/group audit event.
|
||||
type AuthAuditOutcome string
|
||||
|
||||
const (
|
||||
AuthAuditOutcomeSuccess AuthAuditOutcome = "success"
|
||||
AuthAuditOutcomeFailure AuthAuditOutcome = "failure"
|
||||
)
|
||||
|
||||
// AuthAuditCategory groups auth audit events for UI styling and filtering.
|
||||
type AuthAuditCategory string
|
||||
|
||||
const (
|
||||
AuthAuditCategoryAuth AuthAuditCategory = "auth"
|
||||
AuthAuditCategoryUser AuthAuditCategory = "user"
|
||||
AuthAuditCategoryGroup AuthAuditCategory = "group"
|
||||
AuthAuditCategorySelf AuthAuditCategory = "self"
|
||||
)
|
||||
|
||||
// AuthAuditEvent is one append-only record of a login or user/group change.
|
||||
type AuthAuditEvent struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
Action string `json:"action"`
|
||||
Outcome AuthAuditOutcome `json:"outcome"`
|
||||
Category AuthAuditCategory `json:"category"`
|
||||
Actor string `json:"actor"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// AuthAuditStore is the plain JSON payload in auth-audit.json.
|
||||
type AuthAuditStore struct {
|
||||
Events []AuthAuditEvent `json:"events"`
|
||||
}
|
||||
|
||||
// UserCredential is one account stored in passwords.enc.
|
||||
type UserCredential struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
Reference in New Issue
Block a user