Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
34 changes: 34 additions & 0 deletions seclog/nop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// -*- Mode: Go; indent-tabs-mode: t -*-

/*
* Copyright (C) 2026 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package seclog

// nopLogger provides a no-operation [SecurityLogger] implementation.
type nopLogger struct{}

// Ensure [nopLogger] implements [SecurityLogger].
var _ SecurityLogger = (*nopLogger)(nil)

func NewNopLogger() SecurityLogger {
Comment thread
ernestl marked this conversation as resolved.
Comment thread
ernestl marked this conversation as resolved.
return nopLogger{}
}

// LogAny implements [SecurityLogger.LogAny].
func (nopLogger) LogAny(event Event, description string, attrs ...Attr) {
}
88 changes: 88 additions & 0 deletions seclog/nop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// -*- Mode: Go; indent-tabs-mode: t -*-

/*
* Copyright (C) 2026 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package seclog_test

import (
. "gopkg.in/check.v1"

"github.com/snapcore/snapd/seclog"
"github.com/snapcore/snapd/testutil"
)

type NopSuite struct {
testutil.BaseTest
}

var _ = Suite(&NopSuite{})

func (s *NopSuite) SetUpTest(c *C) {
s.BaseTest.SetUpTest(c)
}

func (s *NopSuite) TearDownTest(c *C) {
s.BaseTest.TearDownTest(c)
}

func (s *NopSuite) TestLogLoggerEnabled(c *C) {
logger := seclog.NewNopLogger()
c.Assert(logger, NotNil)

// nop logger discards all messages without error
logger.LogAny(
seclog.Event{Category: "SYS", Name: "sys_logging_enabled", Level: seclog.LevelInfo},
"Security logging enabled",
)
}

func (s *NopSuite) TestLogLoggerDisabled(c *C) {
logger := seclog.NewNopLogger()
c.Assert(logger, NotNil)

// nop logger discards all messages without error
logger.LogAny(
seclog.Event{Category: "SYS", Name: "sys_logging_disabled", Level: seclog.LevelCritical},
"Security logging disabled",
)
}

func (s *NopSuite) TestLogLoginSuccess(c *C) {
logger := seclog.NewNopLogger()
c.Assert(logger, NotNil)

// nop logger discards all messages without error
logger.LogAny(
seclog.Event{Category: "AUTHN", Name: "authn_login_success", Level: seclog.LevelInfo},
"test",
seclog.Attr{Key: "user", Value: seclog.SnapdUser{StoreUserEmail: "user@gmail.com"}},
)
}

func (s *NopSuite) TestLogLoginFailure(c *C) {
logger := seclog.NewNopLogger()
c.Assert(logger, NotNil)

// nop logger discards all messages without error
logger.LogAny(
seclog.Event{Category: "AUTHN", Name: "authn_login_failure", Level: seclog.LevelWarn},
"test",
seclog.Attr{Key: "user", Value: seclog.SnapdUser{StoreUserEmail: "user@gmail.com"}},
seclog.Attr{Key: "error", Value: seclog.Reason{}},
)
}
220 changes: 220 additions & 0 deletions seclog/seclog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// -*- Mode: Go; indent-tabs-mode: t -*-

/*
* Copyright (C) 2026 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package seclog

import (
"fmt"
"sync"
"time"

"github.com/snapcore/snapd/logger"
)

// Level is the importance or severity of a log event.
// The higher the level, the more severe the event.
type Level int

// Log levels.
const (
LevelDebug Level = 1
LevelInfo Level = 2
LevelWarn Level = 3
LevelError Level = 4
LevelCritical Level = 5
)

// String returns a name for the level. If the level has a name, then that name
// in uppercase is returned. Otherwise, a string of the form "UKNOWN(<number>)"
Comment thread
ernestl marked this conversation as resolved.
Outdated
// is returned.
Comment thread
ernestl marked this conversation as resolved.
Outdated
func (l Level) String() string {
switch l {
case LevelDebug:
return "DEBUG"
case LevelInfo:
return "INFO"
case LevelWarn:
return "WARN"
case LevelError:
return "ERROR"
case LevelCritical:
return "CRITICAL"
default:
return fmt.Sprintf("UNKNOWN(%d)", int(l))
}
}

// SnapdUser represents the identity of a user for security log events.
// The slog output schema is defined by [SnapdUser.LogValue], which
// renders Expiration as "never" for zero values instead of emitting a
// zero-value datetime.
Comment thread
ernestl marked this conversation as resolved.
Outdated
Comment thread
ernestl marked this conversation as resolved.
Outdated
type SnapdUser struct {
ID int64 `json:"snapd-user-id"`
StoreUserName string `json:"store-user-name"`
StoreUserEmail string `json:"store-user-email"`
Expiration time.Time `json:"expiration"`
}

// String returns a colon-separated description of the user in the form
// "<ID>:<StoreUserEmail>:<StoreUserName>". Fields that are unset use
// "unknown" as a placeholder. A zero ID is treated as unset.
func (u SnapdUser) String() string {
const unknown = "unknown"

id := unknown
if u.ID != 0 {
id = fmt.Sprintf("%d", u.ID)
}

email := unknown
if u.StoreUserEmail != "" {
email = u.StoreUserEmail
}

name := unknown
if u.StoreUserName != "" {
name = u.StoreUserName
}
Comment thread
ernestl marked this conversation as resolved.

return id + ":" + email + ":" + name
}

// Reason codes are stable identifiers for security audit events.
Comment thread
ernestl marked this conversation as resolved.
const (
Comment thread
ernestl marked this conversation as resolved.
ReasonInvalidCredentials = "invalid-credentials"
ReasonTwoFactorRequired = "two-factor-required"
ReasonTwoFactorFailed = "two-factor-failed"
ReasonInvalidAuthData = "invalid-auth-data"
ReasonPasswordPolicy = "password-policy"
ReasonInternal = "internal"
)

// Reason describes why a security event happened.
type Reason struct {
Code string `json:"code"`
Message string `json:"message"`
}

// String returns a colon-separated representation in the form
// "<Code>:<Message>". Fields that are unset use "unknown" as a
// placeholder.
func (r Reason) String() string {
const unknown = "unknown"

code := unknown
if r.Code != "" {
code = r.Code
}

message := unknown
if r.Message != "" {
message = r.Message
}

return code + ":" + message
}

// Event describes a structured security audit event.
//
// A Version field may be added in the future if a log aggregator or
// consumer requires explicit schema versioning.
type Event struct {
Category string `json:"category"`
Name string `json:"event"`
Level Level `json:"level"`
}

// Attr is a key-value pair attached to a security log event.
type Attr struct {
Key string
Value any
}

// SecurityLogger defines the interface for emitting structured security
// audit events. Implementations receive a fully described [Event] and
// optional [Attr] values, so new event types can be added without
// changing the interface.
type SecurityLogger interface {
LogAny(event Event, description string, attrs ...Attr)
}

var (
globalLogger SecurityLogger = NewNopLogger()
lock sync.Mutex
)

// Setup activates the security logger, replacing any previously
// configured logger.
//
// Setup is intended to be called once during early initialization.
func Setup(l SecurityLogger) {
lock.Lock()
defer lock.Unlock()

globalLogger = l
}

// LogLoggerEnabled logs that the security logger has been enabled.
func LogLoggerEnabled() {
lock.Lock()
defer lock.Unlock()
Comment thread
ernestl marked this conversation as resolved.

logger.Noticef("security logger enabled")
globalLogger.LogAny(
Event{Category: "SYS", Name: "sys_logging_enabled", Level: LevelInfo},
"Security logging enabled",
)
}

// LogLoggerDisabled logs that the security logger has been disabled.
func LogLoggerDisabled() {
lock.Lock()
defer lock.Unlock()

logger.Noticef("security logger disabled")
globalLogger.LogAny(
Event{Category: "SYS", Name: "sys_logging_disabled", Level: LevelCritical},
"Security logging disabled",
)
}

// LogLoginSuccess logs a successful login using the global security logger.
func LogLoginSuccess(user SnapdUser) {
lock.Lock()
defer lock.Unlock()

globalLogger.LogAny(
Event{Category: "AUTHN", Name: "authn_login_success", Level: LevelInfo},
fmt.Sprintf("User %s login success", user.String()),
Comment thread
bboozzoo marked this conversation as resolved.
Attr{Key: "user", Value: user},
)
}

// LogLoginFailure logs a failed login attempt using the global security logger.
func LogLoginFailure(user SnapdUser, reason Reason) {
lock.Lock()
defer lock.Unlock()

globalLogger.LogAny(
Event{Category: "AUTHN", Name: "authn_login_failure", Level: LevelWarn},
fmt.Sprintf("User %s login failure: %s", user.String(), reason.String()),
Comment thread
bboozzoo marked this conversation as resolved.
Attr{Key: "user", Value: user},
Attr{Key: "error", Value: reason},
)
}
Loading
Loading