events: add tenant to event

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-06-14 17:34:42 +02:00
parent e584fd1344
commit 74e578c2bf
15 changed files with 233 additions and 28 deletions

View File

@ -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):

View File

@ -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)

View File

@ -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"),
]
),
),
]

View File

@ -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"):

View File

@ -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.",
),
),
]

View File

@ -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)

View File

@ -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

View File

@ -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,
),
),
]

View File

@ -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__,

View File

@ -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

View File

@ -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;
}

View File

@ -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"

View File

@ -3760,6 +3760,7 @@ msgstr ""
msgid "Template"
msgstr ""
#:
#:
msgid "Tenant"
msgstr ""

View File

@ -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`<span>-</span>`;
}
@ -51,7 +51,7 @@ export class EventInfo extends LitElement {
<span class="pf-c-description-list__text">${t`UID`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${context.pk as string}</div>
<div class="pf-c-description-list__text">${context.pk}</div>
</dd>
</div>
<div class="pf-c-description-list__group">
@ -59,7 +59,7 @@ export class EventInfo extends LitElement {
<span class="pf-c-description-list__text">${t`Name`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${context.name as string}</div>
<div class="pf-c-description-list__text">${context.name}</div>
</dd>
</div>
<div class="pf-c-description-list__group">
@ -67,7 +67,7 @@ export class EventInfo extends LitElement {
<span class="pf-c-description-list__text">${t`App`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${context.app as string}</div>
<div class="pf-c-description-list__text">${context.app}</div>
</dd>
</div>
<div class="pf-c-description-list__group">
@ -75,7 +75,7 @@ export class EventInfo extends LitElement {
<span class="pf-c-description-list__text">${t`Model Name`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${context.model_name as string}</div>
<div class="pf-c-description-list__text">${context.model_name}</div>
</dd>
</div>
</dl>`;
@ -138,7 +138,12 @@ export class EventInfo extends LitElement {
</div>`;
}
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.
<summary>Stacktrace from authentik</summary>
\`\`\`
${body}
${context.message as string}
\`\`\`
</details>
@ -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`
<h3>${t`Affected model:`}</h3>
${this.getModelInfo(this.event.context?.model as EventContext)}
${this.getModelInfo(this.event.context?.model as EventModel)}
`;
case EventMatcherPolicyActionEnum.AuthorizeApplication:
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
<h3>${t`Authorized application:`}</h3>
${this.getModelInfo(this.event.context.authorized_application as EventContext)}
${this.getModelInfo(this.event.context.authorized_application as EventModel)}
</div>
<div class="pf-l-flex__item">
<h3>${t`Using flow`}</h3>
@ -215,15 +222,14 @@ Add any other context about the problem here.
case EventMatcherPolicyActionEnum.SecretView:
return html`
<h3>${t`Secret:`}</h3>
${this.getModelInfo(this.event.context.secret as EventContext)}`;
${this.getModelInfo(this.event.context.secret as EventModel)}`;
case EventMatcherPolicyActionEnum.SystemException:
return html`
<a
class="pf-c-button pf-m-primary"
target="_blank"
href=${this.buildGitHubIssueUrl(
"",
this.event.context.message as string
this.event.context
)}>
${t`Open issue on GitHub...`}
</a>
@ -250,12 +256,12 @@ Add any other context about the problem here.
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
<h3>${t`Binding`}</h3>
${this.getModelInfo(this.event.context.binding as EventContext)}
${this.getModelInfo(this.event.context.binding as EventModel)}
</div>
<div class="pf-l-flex__item">
<h3>${t`Request`}</h3>
<ul class="pf-c-list">
<li>${t`Object`}: ${this.getModelInfo((this.event.context.request as EventContext).obj as EventContext)}</li>
<li>${t`Object`}: ${this.getModelInfo((this.event.context.request as EventContext).obj as EventModel)}</li>
<li><span>${t`Context`}: <code>${JSON.stringify((this.event.context.request as EventContext).context, null, 4)}</code></span></li>
</ul>
</div>
@ -269,12 +275,12 @@ Add any other context about the problem here.
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
<h3>${t`Binding`}</h3>
${this.getModelInfo(this.event.context.binding as EventContext)}
${this.getModelInfo(this.event.context.binding as EventModel)}
</div>
<div class="pf-l-flex__item">
<h3>${t`Request`}</h3>
<ul class="pf-c-list">
<li>${t`Object`}: ${this.getModelInfo((this.event.context.request as EventContext).obj as EventContext)}</li>
<li>${t`Object`}: ${this.getModelInfo((this.event.context.request as EventContext).obj as EventModel)}</li>
<li><span>${t`Context`}: <code>${JSON.stringify((this.event.context.request as EventContext).context, null, 4)}</code></span></li>
</ul>
</div>
@ -310,7 +316,7 @@ Add any other context about the problem here.
return html`<div class="pf-l-flex">
<div class="pf-l-flex__item">
<h3>${t`Using source`}</h3>
${this.getModelInfo(this.event.context.using_source as EventContext)}
${this.getModelInfo(this.event.context.using_source as EventModel)}
</div>
</div>`;
}

View File

@ -44,6 +44,7 @@ export class EventListPage extends TablePage<Event> {
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<Event> {
html`-`,
html`<span>${item.created?.toLocaleString()}</span>`,
html`<span>${item.clientIp || "-"}</span>`,
html`<span>${item.tenant?.name || "-"}</span>`,
html`<a href="#/events/log/${item.pk}">
<i class="fas fas fa-share-square"></i>
</a>`,