enterprise: add full audit log [AUTH-458] (#8177)

* enterprise: add full audit log

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* delegate enabled check to apps

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* move audit middleware to separate app

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanse before diff

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make cleanse include a hash of the values

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix sentry error during lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only use start of hash

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* don't use deepdiff

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add diff ui

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix info for dict

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update release notes

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* enable audit logging for tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix startup with tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* include first 4 chars of raw value?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only log asterisks

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fixup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L
2024-01-24 11:36:06 +01:00
committed by GitHub
parent a351e5e824
commit 4184f8a770
21 changed files with 281 additions and 70 deletions

View File

@ -7,6 +7,8 @@ from dacite.config import Config
from dacite.core import from_dict
from dacite.exceptions import DaciteError
from deepmerge import always_merger
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.db.models import Model
from django.db.models.query_utils import Q
@ -58,9 +60,11 @@ def excluded_models() -> list[type[Model]]:
from django.contrib.auth.models import User as DjangoUser
return (
Tenant,
# Django only classes
DjangoUser,
DjangoGroup,
ContentType,
Permission,
# Base classes
Provider,
Source,
@ -77,6 +81,7 @@ def excluded_models() -> list[type[Model]]:
LicenseUsage,
SCIMGroup,
SCIMUser,
Tenant,
)

View File

@ -47,7 +47,14 @@ USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",)
options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
# used_by API that allows models to specify if they shadow an object
# for example the proxy provider which is built on top of an oauth provider
"authentik_used_by_shadows",
# List fields for which changes are not logged (due to them having dedicated objects)
# for example user's password and last_login
"authentik_signals_ignored_fields",
)
def default_token_duration():
@ -278,6 +285,14 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
("assign_user_permissions", _("Can assign permissions to users")),
("unassign_user_permissions", _("Can unassign permissions from users")),
]
authentik_signals_ignored_fields = [
# Logged by the events `password_set`
# the `password_set` action/signal doesn't currently convey which user
# initiated the password change, so for now we'll log two actions
# ("password", "password_change_date"),
# Logged by `login`
("last_login",),
]
class Provider(SerializerModel):

View File

@ -1,6 +1,4 @@
"""Enterprise app config"""
from functools import lru_cache
from django.conf import settings
from authentik.blueprints.apps import ManagedAppConfig
@ -26,7 +24,6 @@ class AuthentikEnterpriseConfig(EnterpriseConfig):
"""Return true if enterprise is enabled and valid"""
return self.check_enabled() or settings.TEST
@lru_cache()
def check_enabled(self):
"""Actual enterprise check, cached"""
from authentik.enterprise.models import LicenseKey

View File

View File

@ -0,0 +1,19 @@
"""Enterprise app config"""
from django.conf import settings
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseAuditConfig(EnterpriseConfig):
"""Enterprise app config"""
name = "authentik.enterprise.audit"
label = "authentik_enterprise_audit"
verbose_name = "authentik Enterprise.Audit"
default = True
def reconcile_global_install_middleware(self):
"""Install enterprise audit middleware"""
orig_import = "authentik.events.middleware.AuditMiddleware"
new_import = "authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware"
settings.MIDDLEWARE = [new_import if x == orig_import else x for x in settings.MIDDLEWARE]

View File

@ -0,0 +1,121 @@
"""Enterprise audit middleware"""
from copy import deepcopy
from functools import partial
from django.apps.registry import apps
from django.core.files import File
from django.db import connection
from django.db.models import Model
from django.db.models.expressions import BaseExpression, Combinable
from django.db.models.signals import post_init
from django.http import HttpRequest
from authentik.core.models import User
from authentik.events.middleware import AuditMiddleware, should_log_model
from authentik.events.utils import cleanse_dict, sanitize_item
class EnterpriseAuditMiddleware(AuditMiddleware):
"""Enterprise audit middleware"""
_enabled = None
@property
def enabled(self):
"""Lazy check if audit logging is enabled"""
if self._enabled is None:
self._enabled = apps.get_app_config("authentik_enterprise").enabled()
return self._enabled
def connect(self, request: HttpRequest):
super().connect(request)
if not self.enabled:
return
user = getattr(request, "user", self.anonymous_user)
if not user.is_authenticated:
user = self.anonymous_user
if not hasattr(request, "request_id"):
return
post_init.connect(
partial(self.post_init_handler, user=user, request=request),
dispatch_uid=request.request_id,
weak=False,
)
def disconnect(self, request: HttpRequest):
super().disconnect(request)
if not self.enabled:
return
if not hasattr(request, "request_id"):
return
post_init.disconnect(dispatch_uid=request.request_id)
def serialize_simple(self, model: Model) -> dict:
"""Serialize a model in a very simple way. No ForeginKeys or other relationships are
resolved"""
data = {}
deferred_fields = model.get_deferred_fields()
for field in model._meta.concrete_fields:
value = None
if field.get_attname() in deferred_fields:
continue
field_value = getattr(model, field.attname)
if isinstance(value, File):
field_value = value.name
# If current field value is an expression, we are not evaluating it
if isinstance(field_value, (BaseExpression, Combinable)):
continue
field_value = field.to_python(field_value)
data[field.name] = deepcopy(field_value)
return cleanse_dict(data)
def diff(self, before: dict, after: dict) -> dict:
"""Generate diff between dicts"""
diff = {}
for key, value in before.items():
if after.get(key) != value:
diff[key] = {"previous_value": value, "new_value": after.get(key)}
return sanitize_item(diff)
def post_init_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
"""post_init django model handler"""
if not should_log_model(instance):
return
if hasattr(instance, "_previous_state"):
return
before = len(connection.queries)
setattr(instance, "_previous_state", self.serialize_simple(instance))
after = len(connection.queries)
if after > before:
raise AssertionError("More queries generated by serialize_simple")
# pylint: disable=too-many-arguments
def post_save_handler(
self,
user: User,
request: HttpRequest,
sender,
instance: Model,
created: bool,
thread_kwargs: dict | None = None,
**_,
):
if not should_log_model(instance):
return None
thread_kwargs = {}
if hasattr(instance, "_previous_state") or created:
prev_state = getattr(instance, "_previous_state", {})
# Get current state
new_state = self.serialize_simple(instance)
diff = self.diff(prev_state, new_state)
thread_kwargs["diff"] = diff
if not created:
ignored_field_sets = getattr(instance._meta, "authentik_signals_ignored_fields", [])
for field_set in ignored_field_sets:
if set(diff.keys()) == set(field_set):
return None
return super().post_save_handler(
user, request, sender, instance, created, thread_kwargs, **_
)

View File

@ -12,5 +12,6 @@ CELERY_BEAT_SCHEDULE = {
}
TENANT_APPS = [
"authentik.enterprise.audit",
"authentik.enterprise.providers.rac",
]

View File

@ -10,52 +10,36 @@ from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.http import HttpRequest, HttpResponse
from guardian.models import UserObjectPermission
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import (
AuthenticatedSession,
Group,
PropertyMapping,
Provider,
Source,
User,
UserSourceConnection,
)
from authentik.blueprints.v1.importer import excluded_models
from authentik.core.models import Group, User
from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.events.models import Event, EventAction, Notification
from authentik.events.utils import model_to_dict
from authentik.flows.models import FlowToken, Stage
from authentik.lib.sentry import before_send
from authentik.lib.utils.errors import exception_to_string
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMGroup, SCIMUser
from authentik.stages.authenticator_static.models import StaticToken
IGNORED_MODELS = (
Event,
Notification,
UserObjectPermission,
AuthenticatedSession,
StaticToken,
Session,
FlowToken,
Provider,
Source,
PropertyMapping,
UserSourceConnection,
Stage,
OutpostServiceConnection,
Policy,
PolicyBindingModel,
AuthorizationCode,
AccessToken,
RefreshToken,
SCIMUser,
SCIMGroup,
Reputation,
ConnectionToken,
IGNORED_MODELS = tuple(
excluded_models()
+ (
Event,
Notification,
UserObjectPermission,
StaticToken,
Session,
AuthorizationCode,
AccessToken,
RefreshToken,
SCIMUser,
SCIMGroup,
Reputation,
ConnectionToken,
)
)
@ -96,9 +80,11 @@ class AuditMiddleware:
get_response: Callable[[HttpRequest], HttpResponse]
anonymous_user: User = None
logger: BoundLogger
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
self.logger = get_logger().bind()
def _ensure_fallback_user(self):
"""Defer fetching anonymous user until we have to"""
@ -116,21 +102,18 @@ class AuditMiddleware:
user = self.anonymous_user
if not hasattr(request, "request_id"):
return
post_save_handler = partial(self.post_save_handler, user=user, request=request)
pre_delete_handler = partial(self.pre_delete_handler, user=user, request=request)
m2m_changed_handler = partial(self.m2m_changed_handler, user=user, request=request)
post_save.connect(
post_save_handler,
partial(self.post_save_handler, user=user, request=request),
dispatch_uid=request.request_id,
weak=False,
)
pre_delete.connect(
pre_delete_handler,
partial(self.pre_delete_handler, user=user, request=request),
dispatch_uid=request.request_id,
weak=False,
)
m2m_changed.connect(
m2m_changed_handler,
partial(self.m2m_changed_handler, user=user, request=request),
dispatch_uid=request.request_id,
weak=False,
)
@ -173,19 +156,27 @@ class AuditMiddleware:
)
thread.run()
@staticmethod
# pylint: disable=too-many-arguments
def post_save_handler(
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
self,
user: User,
request: HttpRequest,
sender,
instance: Model,
created: bool,
thread_kwargs: Optional[dict] = None,
**_,
):
"""Signal handler for all object's post_save"""
if not should_log_model(instance):
return
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
thread.kwargs.update(thread_kwargs or {})
thread.run()
@staticmethod
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
def pre_delete_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
"""Signal handler for all object's pre_delete"""
if not should_log_model(instance): # pragma: no cover
return
@ -197,9 +188,8 @@ class AuditMiddleware:
model=model_to_dict(instance),
).run()
@staticmethod
def m2m_changed_handler(
user: User, request: HttpRequest, sender, instance: Model, action: str, **_
self, user: User, request: HttpRequest, sender, instance: Model, action: str, **_
):
"""Signal handler for all object's m2m_changed"""
if action not in ["pre_add", "pre_remove", "post_clear"]:

View File

@ -66,7 +66,8 @@ class TestEvents(TestCase):
def test_from_http_clean_querystring(self):
"""Test cleansing query string"""
request = self.factory.get(f"/?token={generate_id()}")
token = generate_id()
request = self.factory.get(f"/?token={token}")
event = Event.new("unittest").from_http(request)
self.assertEqual(
event.context,
@ -82,7 +83,8 @@ class TestEvents(TestCase):
def test_from_http_clean_querystring_flow(self):
"""Test cleansing query string (nested query string like flow executor)"""
nested_qs = {"token": generate_id()}
token = generate_id()
nested_qs = {"token": token}
request = self.factory.get(f"/?{QS_QUERY}={urlencode(nested_qs)}")
event = Event.new("unittest").from_http(request)
self.assertEqual(

View File

@ -28,7 +28,7 @@ from authentik.policies.types import PolicyRequest
# Special keys which are *not* cleaned, even when the default filter
# is matched
ALLOWED_SPECIAL_KEYS = re.compile("passing", flags=re.I)
ALLOWED_SPECIAL_KEYS = re.compile("passing|password_change_date", flags=re.I)
def cleanse_item(key: str, value: Any) -> Any:
@ -40,13 +40,13 @@ def cleanse_item(key: str, value: Any) -> Any:
value[idx] = cleanse_item(key, item)
return value
try:
if SafeExceptionReporterFilter.hidden_settings.search(
key
) and not ALLOWED_SPECIAL_KEYS.search(key):
return SafeExceptionReporterFilter.cleansed_substitute
if not SafeExceptionReporterFilter.hidden_settings.search(key):
return value
if ALLOWED_SPECIAL_KEYS.search(key):
return value
return SafeExceptionReporterFilter.cleansed_substitute
except TypeError: # pragma: no cover
return value
return value
def cleanse_dict(source: dict[Any, Any]) -> dict[Any, Any]:

View File

@ -60,8 +60,6 @@ def sentry_init(**sentry_init_kwargs):
},
}
kwargs.update(**sentry_init_kwargs)
if settings.DEBUG:
kwargs["spotlight"] = True
# pylint: disable=abstract-class-instantiated
sentry_sdk_init(
dsn=CONFIG.get("error_reporting.sentry_dsn"),

View File

@ -136,6 +136,7 @@ class TestPolicyProcess(TestCase):
http_request.user = self.user
http_request.resolver_match = resolve(reverse("authentik_api:user-impersonate-end"))
password = generate_id()
request = PolicyRequest(self.user)
request.set_http_request(http_request)
request.context = {
@ -144,7 +145,7 @@ class TestPolicyProcess(TestCase):
"list": ["foo", "bar"],
"tuple": ("foo", "bar"),
"set": {"foo", "bar"},
"password": generate_id(),
"password": password,
}
}
response = PolicyProcess(binding, request, None).execute()

View File

@ -381,7 +381,7 @@ env = get_env()
_ERROR_REPORTING = CONFIG.get_bool("error_reporting.enabled", False)
if _ERROR_REPORTING:
sentry_env = CONFIG.get("error_reporting.environment", "customer")
sentry_init()
sentry_init(spotlight=DEBUG)
set_tag("authentik.uuid", sha512(str(SECRET_KEY).encode("ascii")).hexdigest()[:16])

View File

@ -3647,6 +3647,7 @@
"authentik.blueprints",
"authentik.core",
"authentik.enterprise",
"authentik.enterprise.audit",
"authentik.enterprise.providers.rac"
],
"title": "App",

1
poetry.lock generated
View File

@ -3362,7 +3362,6 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},

View File

@ -154,6 +154,7 @@ lxml = [
# 4.9.x works with previous libxml2 versions, which is what we get on linux
{ version = "4.9.4", platform = "linux" },
]
jsonpatch = "*"
opencontainers = { extras = ["reggie"], version = "*" }
packaging = "*"
paramiko = "*"
@ -181,7 +182,6 @@ webauthn = "*"
wsproto = "*"
xmlsec = "*"
zxcvbn = "*"
jsonpatch = "*"
[tool.poetry.dev-dependencies]
bandit = "*"

View File

@ -29362,6 +29362,7 @@ components:
- authentik.blueprints
- authentik.core
- authentik.enterprise
- authentik.enterprise.audit
- authentik.enterprise.providers.rac
type: string
description: |-
@ -29415,6 +29416,7 @@ components:
* `authentik.blueprints` - authentik Blueprints
* `authentik.core` - authentik Core
* `authentik.enterprise` - authentik Enterprise
* `authentik.enterprise.audit` - authentik Enterprise.Audit
* `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC
AppleChallengeResponseRequest:
type: object
@ -32415,6 +32417,7 @@ components:
* `authentik.blueprints` - authentik Blueprints
* `authentik.core` - authentik Core
* `authentik.enterprise` - authentik Enterprise
* `authentik.enterprise.audit` - authentik Enterprise.Audit
* `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC
model:
allOf:
@ -32617,6 +32620,7 @@ components:
* `authentik.blueprints` - authentik Blueprints
* `authentik.core` - authentik Core
* `authentik.enterprise` - authentik Enterprise
* `authentik.enterprise.audit` - authentik Enterprise.Audit
* `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC
model:
allOf:
@ -38243,6 +38247,7 @@ components:
* `authentik.blueprints` - authentik Blueprints
* `authentik.core` - authentik Core
* `authentik.enterprise` - authentik Enterprise
* `authentik.enterprise.audit` - authentik Enterprise.Audit
* `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC
model:
allOf:

View File

@ -154,6 +154,12 @@ export class EventViewPage extends AKElement {
<div class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-8-col-on-xl">
<ak-event-info .event=${this.event}></ak-event-info>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Raw event info")}</div>
<div class="pf-c-card__body">
<pre>${JSON.stringify(this.event, null, 4)}</pre>
</div>
</div>
</div>
</section>`;
}

View File

@ -16,6 +16,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFTable from "@patternfly/patternfly/components/Table/table.css";
import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@ -78,6 +79,7 @@ export class EventInfo extends AKElement {
PFButton,
PFFlex,
PFCard,
PFTable,
PFList,
PFDescriptionList,
css`
@ -243,11 +245,52 @@ export class EventInfo extends AKElement {
}
renderModelChanged() {
const diff = this.event.context.diff as unknown as {
[key: string]: { new_value: unknown; previous_value: unknown };
};
let diffBody = html``;
if (diff) {
diffBody = html`<div class="pf-l-flex__item">
<div class="pf-c-card__title">${msg("Changes made:")}</div>
<table class="pf-c-table pf-m-compact pf-m-grid-md" role="grid">
<thead>
<tr role="row">
<th role="columnheader" scope="col">${msg("Key")}</th>
<th role="columnheader" scope="col">${msg("Previous value")}</th>
<th role="columnheader" scope="col">${msg("New value")}</th>
</tr>
</thead>
<tbody role="rowgroup">
${Object.keys(diff).map((key) => {
return html` <tr role="row">
<td role="cell"><pre>${key}</pre></td>
<td role="cell">
<pre>
${JSON.stringify(diff[key].previous_value, null, 4)}</pre
>
</td>
<td role="cell">
<pre>${JSON.stringify(diff[key].new_value, null, 4)}</pre>
</td>
</tr>`;
})}
</tbody>
</table>
</div>
</div>`;
}
return html`
<div class="pf-c-card__title">${msg("Affected model:")}</div>
<div class="pf-c-card__body">
${this.getModelInfo(this.event.context?.model as EventModel)}
<div class="pf-l-flex">
<div class="pf-l-flex__item">
<div class="pf-c-card__title">${msg("Affected model:")}</div>
<div class="pf-c-card__body">
${this.getModelInfo(this.event.context?.model as EventModel)}
</div>
</div>
${diffBody}
</div>
<br />
<ak-expand>${this.renderDefaultResponse()}</ak-expand>
`;
}

View File

@ -305,6 +305,10 @@ A configuration error occurs, for example during the authorization of an applica
Logged when any model is created/updated/deleted, including the user that sent the request.
:::info
Starting with authentik Enterprise 2024.1, `model_*` events also include which fields have been changed and their previous and new values.
:::
### `email_sent`
An email has been sent. Included is the email that was sent.

View File

@ -70,6 +70,10 @@ slug: /releases/2024.1
It allows for authentik operators to manage several authentik installations without having to deploy additional instances.
- Audit log <span class="badge badge--primary">Enterprise</span>
authentik instances which have a valid enterprise license installed will log changes made to models including which fields were changed with previous and new values of the fields. The values are censored if they are sensitive (for example a password hash), however a hash of the changed value will still be logged.
- "Pretend user exists" option for Identification stage
Previously the identification stage would only continue if a user matching the user identifier exists. While this was the intended functionality, this release adds an option to continue to the next stage even if no matching user was found. "Pretend" users cannot authenticate nor receive emails, and don't exist in the database. **This feature is enabled by default.**