diff --git a/authentik/events/api/event.py b/authentik/events/api/event.py index d0c4e396d..10069999e 100644 --- a/authentik/events/api/event.py +++ b/authentik/events/api/event.py @@ -36,6 +36,7 @@ class EventSerializer(ModelSerializer): "client_ip", "created", "expires", + "tenant", ] @@ -76,6 +77,11 @@ class EventsFilter(django_filters.FilterSet): field_name="action", lookup_expr="icontains", ) + tenant_name = django_filters.CharFilter( + field_name="tenant", + lookup_expr="name", + label="Tenant name", + ) # pylint: disable=unused-argument def filter_context_model_pk(self, queryset, name, value): diff --git a/authentik/events/geo.py b/authentik/events/geo.py index 042cfb178..dccd22351 100644 --- a/authentik/events/geo.py +++ b/authentik/events/geo.py @@ -40,9 +40,9 @@ class GeoIPReader: return try: reader = Reader(path) - LOGGER.info("Loaded GeoIP database") self.__reader = reader self.__last_mtime = stat(path).st_mtime + LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime) except OSError as exc: LOGGER.warning("Failed to load GeoIP database", exc=exc) diff --git a/authentik/events/migrations/0016_add_tenant.py b/authentik/events/migrations/0016_add_tenant.py new file mode 100644 index 000000000..f8c199b4e --- /dev/null +++ b/authentik/events/migrations/0016_add_tenant.py @@ -0,0 +1,55 @@ +# Generated by Django 3.2.4 on 2021-06-14 15:33 + +from django.db import migrations, models + +import authentik.events.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_events", "0015_alter_event_action"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="tenant", + field=models.JSONField( + blank=True, default=authentik.events.models.default_tenant + ), + ), + migrations.AlterField( + model_name="event", + name="action", + field=models.TextField( + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("secret_view", "Secret View"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ("property_mapping_exception", "Property Mapping Exception"), + ("system_task_execution", "System Task Execution"), + ("system_task_exception", "System Task Exception"), + ("system_exception", "System Exception"), + ("configuration_error", "Configuration Error"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("email_sent", "Email Sent"), + ("update_available", "Update Available"), + ("custom_", "Custom Prefix"), + ] + ), + ), + ] diff --git a/authentik/events/models.py b/authentik/events/models.py index 136bf79d3..904fe06c8 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -21,11 +21,12 @@ from authentik.core.middleware import ( ) from authentik.core.models import ExpiringModel, Group, User from authentik.events.geo import GEOIP_READER -from authentik.events.utils import cleanse_dict, get_user, sanitize_dict +from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict from authentik.lib.sentry import SentryIgnoredException from authentik.lib.utils.http import get_client_ip from authentik.policies.models import PolicyBindingModel from authentik.stages.email.utils import TemplateEmailMessage +from authentik.tenants.utils import DEFAULT_TENANT LOGGER = get_logger("authentik.events") GAUGE_EVENTS = Gauge( @@ -40,6 +41,11 @@ def default_event_duration(): return now() + timedelta(days=365) +def default_tenant(): + """Get a default value for tenant""" + return sanitize_dict(model_to_dict(DEFAULT_TENANT)) + + class NotificationTransportError(SentryIgnoredException): """Error raised when a notification fails to be delivered""" @@ -95,6 +101,7 @@ class Event(ExpiringModel): context = models.JSONField(default=dict, blank=True) client_ip = models.GenericIPAddressField(null=True) created = models.DateTimeField(auto_now_add=True) + tenant = models.JSONField(default=default_tenant, blank=True) # Shadow the expires attribute from ExpiringModel to override the default duration expires = models.DateTimeField(default=default_event_duration) @@ -133,6 +140,13 @@ class Event(ExpiringModel): """Add data from a Django-HttpRequest, allowing the creation of Events independently from requests. `user` arguments optionally overrides user from requests.""" + if request: + self.context["http_request"] = { + "path": request.get_full_path(), + "method": request.method, + } + if hasattr(request, "tenant"): + self.tenant = sanitize_dict(model_to_dict(request.tenant)) if hasattr(request, "user"): original_user = None if hasattr(request, "session"): diff --git a/authentik/policies/event_matcher/migrations/0017_alter_eventmatcherpolicy_action.py b/authentik/policies/event_matcher/migrations/0017_alter_eventmatcherpolicy_action.py new file mode 100644 index 000000000..1e3ff01d7 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0017_alter_eventmatcherpolicy_action.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.4 on 2021-06-14 15:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0016_alter_eventmatcherpolicy_action"), + ] + + operations = [ + migrations.AlterField( + model_name="eventmatcherpolicy", + name="action", + field=models.TextField( + blank=True, + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("secret_view", "Secret View"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ("property_mapping_exception", "Property Mapping Exception"), + ("system_task_execution", "System Task Execution"), + ("system_task_exception", "System Task Exception"), + ("system_exception", "System Exception"), + ("configuration_error", "Configuration Error"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("email_sent", "Email Sent"), + ("update_available", "Update Available"), + ("custom_", "Custom Prefix"), + ], + help_text="Match created events with this action type. When left empty, all action types will be matched.", + ), + ), + ] diff --git a/authentik/policies/reputation/models.py b/authentik/policies/reputation/models.py index bd03c7564..ac29fd8cd 100644 --- a/authentik/policies/reputation/models.py +++ b/authentik/policies/reputation/models.py @@ -3,11 +3,13 @@ from django.core.cache import cache from django.db import models from django.utils.translation import gettext as _ from rest_framework.serializers import BaseSerializer +from structlog import get_logger from authentik.lib.utils.http import get_client_ip from authentik.policies.models import Policy from authentik.policies.types import PolicyRequest, PolicyResult +LOGGER = get_logger() CACHE_KEY_IP_PREFIX = "authentik_reputation_ip_" CACHE_KEY_USER_PREFIX = "authentik_reputation_user_" @@ -34,9 +36,13 @@ class ReputationPolicy(Policy): passing = True if self.check_ip: score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) + LOGGER.debug("Score for IP", ip=remote_ip, score=score) passing = passing and score <= self.threshold if self.check_username: score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) + LOGGER.debug( + "Score for Username", username=request.user.username, score=score + ) passing = passing and score <= self.threshold return PolicyResult(passing) diff --git a/authentik/policies/reputation/tests.py b/authentik/policies/reputation/tests.py index 4c73e514e..ebd606bb3 100644 --- a/authentik/policies/reputation/tests.py +++ b/authentik/policies/reputation/tests.py @@ -1,7 +1,7 @@ """test reputation signals and policy""" from django.contrib.auth import authenticate from django.core.cache import cache -from django.test import TestCase +from django.test import RequestFactory, TestCase from authentik.core.models import User from authentik.policies.reputation.models import ( @@ -19,7 +19,9 @@ class TestReputationPolicy(TestCase): """test reputation signals and policy""" def setUp(self): - self.test_ip = "255.255.255.255" + self.request_factory = RequestFactory() + self.request = self.request_factory.get("/") + self.test_ip = "127.0.0.1" self.test_username = "test" cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip) cache.delete(CACHE_KEY_USER_PREFIX + self.test_username) @@ -29,7 +31,9 @@ class TestReputationPolicy(TestCase): def test_ip_reputation(self): """test IP reputation""" # Trigger negative reputation - authenticate(None, username=self.test_username, password=self.test_username) + authenticate( + self.request, username=self.test_username, password=self.test_username + ) # Test value in cache self.assertEqual(cache.get(CACHE_KEY_IP_PREFIX + self.test_ip), -1) # Save cache and check db values @@ -39,7 +43,9 @@ class TestReputationPolicy(TestCase): def test_user_reputation(self): """test User reputation""" # Trigger negative reputation - authenticate(None, username=self.test_username, password=self.test_username) + authenticate( + self.request, username=self.test_username, password=self.test_username + ) # Test value in cache self.assertEqual(cache.get(CACHE_KEY_USER_PREFIX + self.test_username), -1) # Save cache and check db values diff --git a/authentik/stages/identification/migrations/0011_alter_identificationstage_user_fields.py b/authentik/stages/identification/migrations/0011_alter_identificationstage_user_fields.py new file mode 100644 index 000000000..c75020272 --- /dev/null +++ b/authentik/stages/identification/migrations/0011_alter_identificationstage_user_fields.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.4 on 2021-06-14 15:32 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_identification", "0010_identificationstage_password_stage"), + ] + + operations = [ + migrations.AlterField( + model_name="identificationstage", + name="user_fields", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("email", "E Mail"), + ("username", "Username"), + ("upn", "Upn"), + ], + max_length=100, + ), + blank=True, + help_text="Fields of the user object to match against. (Hold shift to select multiple options)", + size=None, + ), + ), + ] diff --git a/authentik/tenants/utils.py b/authentik/tenants/utils.py index df89edcdb..8aafc30ce 100644 --- a/authentik/tenants/utils.py +++ b/authentik/tenants/utils.py @@ -9,6 +9,7 @@ from authentik.lib.config import CONFIG from authentik.tenants.models import Tenant _q_default = Q(default=True) +DEFAULT_TENANT = Tenant(domain="fallback") def get_tenant_for_request(request: HttpRequest) -> Tenant: @@ -17,13 +18,13 @@ def get_tenant_for_request(request: HttpRequest) -> Tenant: Q(domain__iendswith=request.get_host()) | _q_default ) if not db_tenants.exists(): - return Tenant(domain="fallback") + return DEFAULT_TENANT return db_tenants.first() def context_processor(request: HttpRequest) -> dict[str, Any]: """Context Processor that injects tenant object into every template""" - tenant = getattr(request, "tenant", Tenant(domain="fallback")) + tenant = getattr(request, "tenant", DEFAULT_TENANT) return { "tenant": tenant, "ak_version": __version__, diff --git a/schema.yml b/schema.yml index 47b00675d..e5698c323 100644 --- a/schema.yml +++ b/schema.yml @@ -3418,6 +3418,11 @@ paths: description: A search term. schema: type: string + - in: query + name: tenant_name + schema: + type: string + description: Tenant name - in: query name: username schema: @@ -3534,6 +3539,11 @@ paths: description: A search term. schema: type: string + - in: query + name: tenant_name + schema: + type: string + description: Tenant name - in: query name: top_n schema: @@ -19081,6 +19091,9 @@ components: expires: type: string format: date-time + tenant: + type: object + additionalProperties: {} required: - action - app @@ -19207,6 +19220,9 @@ components: expires: type: string format: date-time + tenant: + type: object + additionalProperties: {} required: - action - app diff --git a/web/src/api/Events.ts b/web/src/api/Events.ts index e5c297951..1cf2b4c92 100644 --- a/web/src/api/Events.ts +++ b/web/src/api/Events.ts @@ -8,10 +8,22 @@ export interface EventUser { } export interface EventContext { - [key: string]: EventContext | string | number | string[]; + [key: string]: EventContext | EventModel | string | number | string[]; } export interface EventWithContext extends Event { user: EventUser; context: EventContext; } + +export interface EventModel { + pk: string; + name: string; + app: string; + model_name: string; +} + +export interface EventRequest { + path: string; + method: string; +} diff --git a/web/src/locales/en.po b/web/src/locales/en.po index 026cf7bfa..6cf58af8c 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -3768,6 +3768,7 @@ msgstr "Task finished with warnings" msgid "Template" msgstr "Template" +#: src/pages/events/EventListPage.ts #: src/pages/tenants/TenantListPage.ts msgid "Tenant" msgstr "Tenant" diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index cd3732266..e814e6791 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -3760,6 +3760,7 @@ msgstr "" msgid "Template" msgstr "" +#: #: msgid "Tenant" msgstr "" diff --git a/web/src/pages/events/EventInfo.ts b/web/src/pages/events/EventInfo.ts index 19e7088e2..da49dda0c 100644 --- a/web/src/pages/events/EventInfo.ts +++ b/web/src/pages/events/EventInfo.ts @@ -5,7 +5,7 @@ import { EventMatcherPolicyActionEnum, FlowsApi } from "authentik-api"; import "../../elements/Spinner"; import "../../elements/Expand"; import { PFSize } from "../../elements/Spinner"; -import { EventContext, EventWithContext } from "../../api/Events"; +import { EventContext, EventModel, EventWithContext } from "../../api/Events"; import { DEFAULT_CONFIG } from "../../api/Config"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; @@ -41,7 +41,7 @@ export class EventInfo extends LitElement { ]; } - getModelInfo(context: EventContext): TemplateResult { + getModelInfo(context: EventModel): TemplateResult { if (context === null) { return html`-`; } @@ -51,7 +51,7 @@ export class EventInfo extends LitElement { ${t`UID`}
-
${context.pk as string}
+
${context.pk}
@@ -59,7 +59,7 @@ export class EventInfo extends LitElement { ${t`Name`}
-
${context.name as string}
+
${context.name}
@@ -67,7 +67,7 @@ export class EventInfo extends LitElement { ${t`App`}
-
${context.app as string}
+
${context.app}
@@ -75,7 +75,7 @@ export class EventInfo extends LitElement { ${t`Model Name`}
-
${context.model_name as string}
+
${context.model_name}
`; @@ -138,7 +138,12 @@ export class EventInfo extends LitElement { `; } - buildGitHubIssueUrl(title: string, body: string): string { + buildGitHubIssueUrl(context: EventContext): string { + const httpRequest = this.event.context.http_request as EventContext; + let title = ""; + if (httpRequest) { + title = `${httpRequest?.method} ${httpRequest?.path}`; + } // https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-issues/about-automation-for-issues-and-pull-requests-with-query-parameters const fullBody = ` **Describe the bug** @@ -162,7 +167,7 @@ If applicable, add screenshots to help explain your problem. Stacktrace from authentik \`\`\` -${body} +${context.message as string} \`\`\` @@ -174,7 +179,9 @@ ${body} **Additional context** Add any other context about the problem here. `; - return `https://github.com/goauthentik/authentik/issues/new?labels=bug+from_authentik&title=${encodeURIComponent(title)}&body=${encodeURIComponent(fullBody)}`; + return `https://github.com/goauthentik/authentik/issues/ + new?labels=bug+from_authentik&title=${encodeURIComponent(title)} + &body=${encodeURIComponent(fullBody)}`.trim(); } render(): TemplateResult { @@ -187,13 +194,13 @@ Add any other context about the problem here. case EventMatcherPolicyActionEnum.ModelDeleted: return html`

${t`Affected model:`}

- ${this.getModelInfo(this.event.context?.model as EventContext)} + ${this.getModelInfo(this.event.context?.model as EventModel)} `; case EventMatcherPolicyActionEnum.AuthorizeApplication: return html`

${t`Authorized application:`}

- ${this.getModelInfo(this.event.context.authorized_application as EventContext)} + ${this.getModelInfo(this.event.context.authorized_application as EventModel)}

${t`Using flow`}

@@ -215,15 +222,14 @@ Add any other context about the problem here. case EventMatcherPolicyActionEnum.SecretView: return html`

${t`Secret:`}

- ${this.getModelInfo(this.event.context.secret as EventContext)}`; + ${this.getModelInfo(this.event.context.secret as EventModel)}`; case EventMatcherPolicyActionEnum.SystemException: return html` ${t`Open issue on GitHub...`} @@ -250,12 +256,12 @@ Add any other context about the problem here. return html`

${t`Binding`}

- ${this.getModelInfo(this.event.context.binding as EventContext)} + ${this.getModelInfo(this.event.context.binding as EventModel)}

${t`Request`}

    -
  • ${t`Object`}: ${this.getModelInfo((this.event.context.request as EventContext).obj as EventContext)}
  • +
  • ${t`Object`}: ${this.getModelInfo((this.event.context.request as EventContext).obj as EventModel)}
  • ${t`Context`}: ${JSON.stringify((this.event.context.request as EventContext).context, null, 4)}
@@ -269,12 +275,12 @@ Add any other context about the problem here. return html`

${t`Binding`}

- ${this.getModelInfo(this.event.context.binding as EventContext)} + ${this.getModelInfo(this.event.context.binding as EventModel)}

${t`Request`}

    -
  • ${t`Object`}: ${this.getModelInfo((this.event.context.request as EventContext).obj as EventContext)}
  • +
  • ${t`Object`}: ${this.getModelInfo((this.event.context.request as EventContext).obj as EventModel)}
  • ${t`Context`}: ${JSON.stringify((this.event.context.request as EventContext).context, null, 4)}
@@ -310,7 +316,7 @@ Add any other context about the problem here. return html`

${t`Using source`}

- ${this.getModelInfo(this.event.context.using_source as EventContext)} + ${this.getModelInfo(this.event.context.using_source as EventModel)}
`; } diff --git a/web/src/pages/events/EventListPage.ts b/web/src/pages/events/EventListPage.ts index c4165dcca..16aeffc3c 100644 --- a/web/src/pages/events/EventListPage.ts +++ b/web/src/pages/events/EventListPage.ts @@ -44,6 +44,7 @@ export class EventListPage extends TablePage { new TableColumn(t`User`, "user"), new TableColumn(t`Creation Date`, "created"), new TableColumn(t`Client IP`, "client_ip"), + new TableColumn(t`Tenant`, "tenant_name"), new TableColumn(""), ]; } @@ -62,6 +63,7 @@ export class EventListPage extends TablePage { html`-`, html`${item.created?.toLocaleString()}`, html`${item.clientIp || "-"}`, + html`${item.tenant?.name || "-"}`, html` `,