From 44049fe835437779dc5cad5c3b2e4de4f883f203 Mon Sep 17 00:00:00 2001 From: Luciano Lo Giudice Date: Tue, 2 Jun 2026 19:43:04 -0300 Subject: [PATCH] Implement a security event logging module Signed-off-by: Luciano Lo Giudice --- microceph/logger/selog.go | 141 ++++++++++++++++++++++++++++++++ microceph/logger/selog_test.go | 143 +++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 microceph/logger/selog.go create mode 100644 microceph/logger/selog_test.go diff --git a/microceph/logger/selog.go b/microceph/logger/selog.go new file mode 100644 index 00000000..84893d77 --- /dev/null +++ b/microceph/logger/selog.go @@ -0,0 +1,141 @@ +// Copyright 2026 Canonical Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logger + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// Parameters for generating a security log. +type LogParams struct { + Level string + Msg string + AppID string + Event string + Detail string +} + +type LogCallback func(msg string) + +var ( + // Overridable callback that outputs a log string. + logCallbackFunc LogCallback = defaultLogOutput + defaults = make(map[string]string) +) + +func defaultLogOutput(msg string) { + Warn(msg) +} + +func RegisterLogCallback(fn LogCallback) LogCallback { + prev := logCallbackFunc + logCallbackFunc = fn + return prev +} + +func RegisterDefaults(dfls map[string]string) map[string]string { + prev := defaults + defaults = dfls + return prev +} + +// securityLog represents the JSON structure of a security event. +type securityLog struct { + Level string `json:"level"` + Msg string `json:"msg"` + Type string `json:"type"` + Datetime string `json:"datetime"` + AppID string `json:"appid"` + Event string `json:"event"` + Detail string `json:"detail"` + Description string `json:"description"` +} + +func makeLogStr(description, level, msg, appID, event, detail string) (string, error) { + if !strings.HasPrefix(event, "sys_") && + !strings.HasPrefix(event, "authn_") && + !strings.HasPrefix(event, "authz_") { + return "", fmt.Errorf("event must start with one of sys, authn or authz") + } + + levelUpper := strings.ToUpper(level) + if levelUpper != "INFO" && levelUpper != "WARN" && levelUpper != "ERROR" { + return "", fmt.Errorf("level must be one of INFO, WARN, ERROR") + } + + nowUTC := time.Now().UTC().Format("2006-01-02T15:04:05.000000-07:00") + obj := securityLog{ + Level: levelUpper, + Msg: msg, + Type: "security", + Datetime: nowUTC, + AppID: appID, + Event: event, + Detail: detail, + Description: description, + } + + data, err := json.Marshal(obj) + if err != nil { + return "", fmt.Errorf("failed to marshal security log: %w", err) + } + + return string(data), nil +} + +func mapDefault(val string, key string) string { + if val != "" { + return val + } + + ret, ok := defaults[key] + if ok { + return ret + } + + return val +} + +func Log(description string, params LogParams) error { + level := params.Level + if level == "" { + level = "WARN" + } + + msg := params.Msg + if msg == "" { + msg = description + } + + msg = mapDefault(msg, "msg") + detail := mapDefault(params.Detail, "detail") + appID := mapDefault(params.AppID, "appid") + event := mapDefault(params.Event, "event") + + logStr, err := makeLogStr(description, level, msg, appID, event, detail) + if err != nil { + return err + } + + cb := logCallbackFunc + if cb != nil { + cb(logStr) + } + + return nil +} diff --git a/microceph/logger/selog_test.go b/microceph/logger/selog_test.go new file mode 100644 index 00000000..6f002173 --- /dev/null +++ b/microceph/logger/selog_test.go @@ -0,0 +1,143 @@ +// Copyright 2026 Canonical Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logger + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestLogSuccess(t *testing.T) { + var loggedMsg string + oldCallback := RegisterLogCallback(func(msg string) { + loggedMsg = msg + }) + defer RegisterLogCallback(oldCallback) + + params := LogParams{ + Level: "info", + Msg: "test msg", + AppID: "test_app", + Event: "sys_test", + Detail: "test detail", + } + + err := Log("test description", params) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if loggedMsg == "" { + t.Fatal("Expected log message to be captured, but got empty string") + } + + var parsed securityLog + err = json.Unmarshal([]byte(loggedMsg), &parsed) + if err != nil { + t.Fatalf("Failed to parse logged message as JSON: %v", err) + } + + if parsed.Level != "INFO" { + t.Errorf("Expected Level to be 'INFO', got '%s'", parsed.Level) + } + if parsed.Msg != "test msg" { + t.Errorf("Expected Msg to be 'test msg', got '%s'", parsed.Msg) + } + if parsed.Type != "security" { + t.Errorf("Expected Type to be 'security', got '%s'", parsed.Type) + } + if parsed.AppID != "test_app" { + t.Errorf("Expected AppID to be 'test_app', got '%s'", parsed.AppID) + } + if parsed.Event != "sys_test" { + t.Errorf("Expected Event to be 'sys_test', got '%s'", parsed.Event) + } + if parsed.Detail != "test detail" { + t.Errorf("Expected Detail to be 'test detail', got '%s'", parsed.Detail) + } + if parsed.Description != "test description" { + t.Errorf("Expected Description to be 'test description', got '%s'", parsed.Description) + } + if parsed.Datetime == "" { + t.Error("Expected Datetime to be non-empty") + } +} + +func TestLogInvalidEvent(t *testing.T) { + params := LogParams{ + Event: "invalid_event", + } + err := Log("test description", params) + if err == nil { + t.Fatal("Expected error due to invalid event prefix, but got nil") + } + if !strings.Contains(err.Error(), "event must start with") { + t.Errorf("Expected error message to contain 'event must start with', got '%v'", err) + } +} + +func TestLogInvalidLevel(t *testing.T) { + params := LogParams{ + Level: "DEBUG", + Event: "sys_test", + } + err := Log("test description", params) + if err == nil { + t.Fatal("Expected error due to invalid level, but got nil") + } + if !strings.Contains(err.Error(), "level must be one of") { + t.Errorf("Expected error message to contain 'level must be one of', got '%v'", err) + } +} + +func TestLogDefaults(t *testing.T) { + var loggedMsg string + oldCallback := RegisterLogCallback(func(msg string) { + loggedMsg = msg + }) + defer RegisterLogCallback(oldCallback) + + oldDefaults := RegisterDefaults(map[string]string{ + "appid": "default_appid", + "detail": "default_detail", + }) + defer RegisterDefaults(oldDefaults) + + params := LogParams{ + Event: "authn_login", + } + + err := Log("test desc", params) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + var parsed securityLog + err = json.Unmarshal([]byte(loggedMsg), &parsed) + if err != nil { + t.Fatalf("Failed to parse logged message as JSON: %v", err) + } + + if parsed.AppID != "default_appid" { + t.Errorf("Expected AppID to default to 'default_appid', got '%s'", parsed.AppID) + } + if parsed.Detail != "default_detail" { + t.Errorf("Expected Detail to default to 'default_detail', got '%s'", parsed.Detail) + } + if parsed.Msg != "test desc" { + t.Errorf("Expected Msg to default to description 'test desc', got '%s'", parsed.Msg) + } +}