Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CBG-4060: Role-based audit filtering #6980

Merged
merged 9 commits into from
Jul 22, 2024
3 changes: 3 additions & 0 deletions auth/principal.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ type User interface {
// Changes the user's password.
SetPassword(password string) error

// GetRoles returns the set of roles the user belongs to, initializing them if necessary.
GetRoles() []Role

// The set of Roles the user belongs to (including ones given to it by the sync function and by OIDC/JWT)
// Returns nil if invalidated
RoleNames() ch.TimedSet
Expand Down
14 changes: 12 additions & 2 deletions base/logger_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ func (al *AuditLogger) shouldLog(id AuditID, ctx context.Context) bool {

// shouldLogAuditEventForUserAndRole returns true if the request should be logged
func shouldLogAuditEventForUserAndRole(logCtx *LogContext) bool {
if logCtx.UserDomain == "" && logCtx.Username == "" ||
len(logCtx.DbLogConfig.Audit.DisabledUsers) == 0 {
if (logCtx.UserDomain == "" && logCtx.Username == "") ||
len(logCtx.DbLogConfig.Audit.DisabledRoles) == 0 && len(logCtx.DbLogConfig.Audit.DisabledUsers) == 0 {
// early return for common cases: no user on context or no disabled users or roles
return true
}
Expand All @@ -229,5 +229,15 @@ func shouldLogAuditEventForUserAndRole(logCtx *LogContext) bool {
}
}

// if any of the user's roles are disabled, then don't log the event
for role := range logCtx.UserRolesForAuditFiltering {
if _, isDisabled := logCtx.DbLogConfig.Audit.DisabledRoles[AuditLoggingPrincipal{
Domain: string(logCtx.UserDomain),
Name: role,
}]; isDisabled {
return false
}
}

return true
}
20 changes: 15 additions & 5 deletions base/logging_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ type LogContext struct {
// Username is the name of the authenticated user
Username string
// UserDomain can determine whether the authenticated user is a sync gateway user or a couchbase RBAC user
UserDomain userIDDomain
UserDomain UserIDDomain
// UserRolesForAuditFiltering is a list of the authenticated user's roles to be used to determine audit log filtering, the domain for these roles is the same as the UserDomain
UserRolesForAuditFiltering map[string]struct{}

// RequestHost is the HTTP Host of the request associated with this log.
RequestHost string
Expand Down Expand Up @@ -94,6 +96,7 @@ type DbAuditLogConfig struct {
Enabled bool
EnabledEvents map[AuditID]struct{}
DisabledUsers map[AuditLoggingPrincipal]struct{}
DisabledRoles map[AuditLoggingPrincipal]struct{}
}

type AuditLoggingPrincipal struct {
Expand Down Expand Up @@ -157,6 +160,7 @@ func (lc *LogContext) getCopy() LogContext {
RequestAdditionalAuditFields: lc.RequestAdditionalAuditFields,
Username: lc.Username,
UserDomain: lc.UserDomain,
UserRolesForAuditFiltering: lc.UserRolesForAuditFiltering,
RequestHost: lc.RequestHost,
RequestRemoteAddr: lc.RequestRemoteAddr,
EffectiveUserID: lc.EffectiveUserID,
Expand Down Expand Up @@ -260,18 +264,24 @@ type EffectiveUserPair struct {
Domain string `json:"domain"`
}

type userIDDomain string
type UserIDDomain string

const (
UserDomainSyncGateway userIDDomain = "sgw"
UserDomainCBServer userIDDomain = "cbs"
UserDomainSyncGateway UserIDDomain = "sgw"
UserDomainCBServer UserIDDomain = "cbs"
UserDomainBuiltin = "builtin" // internal users (e.g. SG bootstrap user)
)

func UserLogCtx(parent context.Context, username string, domain userIDDomain) context.Context {
func UserLogCtx(parent context.Context, username string, domain UserIDDomain, roles []string) context.Context {
newCtx := getLogCtx(parent)
newCtx.Username = username
newCtx.UserDomain = domain
if len(roles) > 0 {
newCtx.UserRolesForAuditFiltering = make(map[string]struct{}, len(roles))
for _, role := range roles {
newCtx.UserRolesForAuditFiltering[role] = struct{}{}
}
}
return LogContextWith(parent, &newCtx)
}

Expand Down
8 changes: 4 additions & 4 deletions base/main_test_bucket_pool_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ func getTestBucketSpec(testBucketName tbpBucketName) BucketSpec {
}

// RequireNumTestBuckets skips the given test if there are not enough test buckets available to use.
func RequireNumTestBuckets(t *testing.T, numRequired int) {
usable := GTestBucketPool.numUsableBuckets()
func RequireNumTestBuckets(t testing.TB, numRequired int) {
usable := GTestBucketPool.NumUsableBuckets()
if usable < numRequired {
t.Skipf("Only had %d usable test buckets available (test requires %d)", usable, numRequired)
}
Expand All @@ -67,8 +67,8 @@ func RequireNumTestDataStores(t testing.TB, numRequired int) {
}
}

// numUsableBuckets returns the total number of buckets in the pool that can be used by a test.
func (tbp *TestBucketPool) numUsableBuckets() int {
// NumUsableBuckets returns the total number of buckets in the pool that can be used by a test.
func (tbp *TestBucketPool) NumUsableBuckets() int {
if !tbp.integrationMode {
// we can create virtually endless walrus buckets,
// so report back 10 to match a fully available CBS bucket pool.
Expand Down
8 changes: 8 additions & 0 deletions rest/admin_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,7 @@ type HandleDbAuditConfigBody struct {
Enabled *bool `json:"enabled,omitempty"`
Events map[string]any `json:"events,omitempty"`
DisabledUsers []base.AuditLoggingPrincipal `json:"disabled_users,omitempty"`
DisabledRoles []base.AuditLoggingPrincipal `json:"disabled_roles,omitempty"`
}

type HandleDbAuditConfigBodyVerboseEvent struct {
Expand All @@ -721,6 +722,7 @@ func (h *handler) handleGetDbAuditConfig() error {
etagVersion string
dbAuditEnabled bool
dbAuditDisabledUsers []base.AuditLoggingPrincipal
dbAuditDisabledRoles []base.AuditLoggingPrincipal
enabledEvents = make(map[base.AuditID]struct{})
)

Expand Down Expand Up @@ -748,6 +750,7 @@ func (h *handler) handleGetDbAuditConfig() error {
enabledEvents[base.AuditID(event)] = struct{}{}
}
dbAuditDisabledUsers = runtimeConfig.Logging.Audit.DisabledUsers
dbAuditDisabledRoles = runtimeConfig.Logging.Audit.DisabledRoles
}
} else {
return base.HTTPErrorf(http.StatusServiceUnavailable, "audit config not available in non-persistent mode")
Expand Down Expand Up @@ -778,6 +781,7 @@ func (h *handler) handleGetDbAuditConfig() error {
Enabled: &dbAuditEnabled,
Events: events,
DisabledUsers: dbAuditDisabledUsers,
DisabledRoles: dbAuditDisabledRoles,
}

h.setEtag(etagVersion)
Expand Down Expand Up @@ -861,6 +865,7 @@ func mutateConfigFromDbAuditConfigBody(isReplace bool, existingAuditConfig *DbAu
if isReplace {
existingAuditConfig.Enabled = requestAuditConfig.Enabled
existingAuditConfig.DisabledUsers = requestAuditConfig.DisabledUsers
existingAuditConfig.DisabledRoles = requestAuditConfig.DisabledRoles

// we don't need to do anything to "disable" events, other than not enable them
existingAuditConfig.EnabledEvents = func() []uint {
Expand All @@ -879,6 +884,9 @@ func mutateConfigFromDbAuditConfigBody(isReplace bool, existingAuditConfig *DbAu
if requestAuditConfig.DisabledUsers != nil {
existingAuditConfig.DisabledUsers = requestAuditConfig.DisabledUsers
}
if requestAuditConfig.DisabledRoles != nil {
existingAuditConfig.DisabledRoles = requestAuditConfig.DisabledRoles
}

for i, event := range existingAuditConfig.EnabledEvents {
if shouldEnable, ok := eventsToChange[base.AuditID(event)]; ok {
Expand Down
95 changes: 84 additions & 11 deletions rest/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/couchbase/sync_gateway/auth"
"github.com/couchbase/sync_gateway/base"
"github.com/couchbase/sync_gateway/channels"
"github.com/couchbase/sync_gateway/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -38,13 +39,18 @@ func TestAuditLoggingFields(t *testing.T) {
base.InitializeMemoryLoggers()

const (
requestInfoHeaderName = "extra-audit-logging-header"
requestUser = "alice"
filteredPublicUsername = "bob"
filteredAdminUsername = "TestAuditLoggingFields-charlie"
filteredAdminPassword = "password"
unauthorizedAdminUsername = "TestAuditLoggingFields-alice"
unauthorizedAdminPassword = "password"
requestInfoHeaderName = "extra-audit-logging-header"
requestUser = "alice"
filteredPublicUsername = "bob"
filteredPublicRoleUsername = "charlie"
filteredPublicRoleName = "observer"
filteredAdminUsername = "TestAuditLoggingFields-charlie"
unfilteredAdminRoleUsername = "TestAuditLoggingFields-diana"
filteredAdminRoleUsername = "TestAuditLoggingFields-bob"
unauthorizedAdminUsername = "TestAuditLoggingFields-alice"
)
var (
filteredAdminRoleName = BucketFullAccessRole.RoleName
)

rt := NewRestTester(t, &RestTesterConfig{
Expand Down Expand Up @@ -79,6 +85,10 @@ func TestAuditLoggingFields(t *testing.T) {
{Name: filteredPublicUsername, Domain: string(base.UserDomainSyncGateway)},
{Name: filteredAdminUsername, Domain: string(base.UserDomainCBServer)},
},
DisabledRoles: []base.AuditLoggingPrincipal{
{Name: filteredPublicRoleName, Domain: string(base.UserDomainSyncGateway)},
{Name: filteredAdminRoleName, Domain: string(base.UserDomainCBServer)},
},
},
}

Expand All @@ -87,14 +97,35 @@ func TestAuditLoggingFields(t *testing.T) {

rt.CreateUser(requestUser, nil)
rt.CreateUser(filteredPublicUsername, nil)
rt.CreateRole(filteredPublicRoleName, []string{channels.AllChannelWildcard})
rt.CreateUser(filteredPublicRoleUsername, nil, filteredPublicRoleName)
if runServerRBACTests {
eps, httpClient, err := rt.ServerContext().ObtainManagementEndpointsAndHTTPClient()
require.NoError(t, err)
MakeUser(t, httpClient, eps[0], filteredAdminUsername, filteredAdminPassword, []string{fmt.Sprintf("%s[%s]", MobileSyncGatewayRole.RoleName, rt.Bucket().GetName())})

MakeUser(t, httpClient, eps[0], filteredAdminUsername, RestTesterDefaultUserPassword, []string{
fmt.Sprintf("%s[%s]", MobileSyncGatewayRole.RoleName, rt.Bucket().GetName()),
})
defer DeleteUser(t, httpClient, eps[0], filteredAdminUsername)
MakeUser(t, httpClient, eps[0], unauthorizedAdminUsername, unauthorizedAdminPassword, []string{})
MakeUser(t, httpClient, eps[0], filteredAdminRoleUsername, RestTesterDefaultUserPassword, []string{
fmt.Sprintf("%s[%s]", filteredAdminRoleName, rt.Bucket().GetName()),
})
defer DeleteUser(t, httpClient, eps[0], filteredAdminRoleUsername)
MakeUser(t, httpClient, eps[0], unauthorizedAdminUsername, RestTesterDefaultUserPassword, []string{})
defer DeleteUser(t, httpClient, eps[0], unauthorizedAdminUsername)

// if we have another bucket available, use it to test cross-bucket role filtering (to ensure it doesn't)
if base.GTestBucketPool.NumUsableBuckets() >= 2 {
differentBucket := base.GetTestBucket(t)
defer differentBucket.Close(base.TestCtx(t))
differentBucketName := differentBucket.GetName()

MakeUser(t, httpClient, eps[0], unfilteredAdminRoleUsername, RestTesterDefaultUserPassword, []string{
fmt.Sprintf("%s[%s]", filteredAdminRoleName, differentBucketName),
fmt.Sprintf("%s[%s]", MobileSyncGatewayRole.RoleName, rt.Bucket().GetName()),
})
defer DeleteUser(t, httpClient, eps[0], unfilteredAdminRoleUsername)
}
}

// auditFieldValueIgnored is a special value for an audit field to skip value-specific checks whilst still ensuring the field property is set
Expand Down Expand Up @@ -277,7 +308,7 @@ func TestAuditLoggingFields(t *testing.T) {
if !rt.AdminInterfaceAuthentication {
t.Skip("Skipping subtest that requires admin auth")
}
RequireStatus(t, rt.SendAdminRequestWithAuth(http.MethodGet, "/db/", "", unauthorizedAdminUsername, unauthorizedAdminPassword), http.StatusForbidden)
RequireStatus(t, rt.SendAdminRequestWithAuth(http.MethodGet, "/db/", "", unauthorizedAdminUsername, RestTesterDefaultUserPassword), http.StatusForbidden)
},
expectedAuditEventFields: map[base.AuditID]base.AuditFields{
base.AuditIDAdminUserAuthorizationFailed: {
Expand All @@ -295,6 +326,12 @@ func TestAuditLoggingFields(t *testing.T) {
RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/db/", "", filteredPublicUsername), http.StatusOK)
},
},
{
name: "filtered public role request",
auditableAction: func(t testing.TB) {
RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/db/", "", filteredPublicRoleUsername), http.StatusOK)
},
},
{
name: "filtered admin request",
auditableAction: func(t testing.TB) {
Expand All @@ -304,7 +341,43 @@ func TestAuditLoggingFields(t *testing.T) {
if !rt.AdminInterfaceAuthentication {
t.Skip("Skipping subtest that requires admin auth")
}
RequireStatus(t, rt.SendAdminRequestWithAuth(http.MethodGet, "/db/", "", filteredAdminUsername, filteredAdminPassword), http.StatusOK)
RequireStatus(t, rt.SendAdminRequestWithAuth(http.MethodGet, "/db/", "", filteredAdminUsername, RestTesterDefaultUserPassword), http.StatusOK)
},
},
{
name: "filtered admin role request",
auditableAction: func(t testing.TB) {
if !runServerRBACTests {
t.Skip("Skipping subtest that requires admin RBAC")
}
if !rt.AdminInterfaceAuthentication {
t.Skip("Skipping subtest that requires admin auth")
}
RequireStatus(t, rt.SendAdminRequestWithAuth(http.MethodGet, "/db/", "", filteredAdminRoleUsername, RestTesterDefaultUserPassword), http.StatusOK)
},
},
{
name: "authed admin request role filtered on different bucket",
auditableAction: func(t testing.TB) {
if !rt.AdminInterfaceAuthentication {
t.Skip("Skipping subtest that requires admin auth")
}
base.RequireNumTestBuckets(t, 2)
RequireStatus(t, rt.SendAdminRequestWithAuth(http.MethodGet, "/db/", "", unfilteredAdminRoleUsername, RestTesterDefaultUserPassword), http.StatusOK)
},
expectedAuditEventFields: map[base.AuditID]base.AuditFields{
base.AuditIDAdminUserAuthenticated: {
base.AuditFieldCorrelationID: auditFieldValueIgnored,
// base.AuditFieldRealUserID: map[string]any{"domain": "cbs", "user": unfilteredAdminRoleUsername},
},
base.AuditIDReadDatabase: {
base.AuditFieldCorrelationID: auditFieldValueIgnored,
base.AuditFieldRealUserID: map[string]any{"domain": "cbs", "user": unfilteredAdminRoleUsername},
},
base.AuditIDAdminHTTPAPIRequest: {
base.AuditFieldHTTPMethod: http.MethodGet,
base.AuditFieldHTTPPath: "/db/",
},
},
},
}
Expand Down
5 changes: 5 additions & 0 deletions rest/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2286,14 +2286,19 @@ func (c *DbConfig) toDbLogConfig(ctx context.Context) *base.DbLogConfig {

// user/role filtering
disabledUsers := make(map[base.AuditLoggingPrincipal]struct{}, len(c.Logging.Audit.DisabledUsers))
disabledRoles := make(map[base.AuditLoggingPrincipal]struct{}, len(c.Logging.Audit.DisabledRoles))
for _, user := range c.Logging.Audit.DisabledUsers {
disabledUsers[user] = struct{}{}
}
for _, role := range c.Logging.Audit.DisabledRoles {
disabledRoles[role] = struct{}{}
}

aud = &base.DbAuditLogConfig{
Enabled: base.BoolDefault(l.Audit.Enabled, false),
EnabledEvents: enabledEvents,
DisabledUsers: disabledUsers,
DisabledRoles: disabledRoles,
}
}

Expand Down
Loading
Loading