Skip to content

Organizations: show audit logs #8588

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

Merged
merged 12 commits into from
Nov 8, 2021
Merged
Show file tree
Hide file tree
Changes from 10 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
4 changes: 3 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ and some of the core features of Read the Docs.
:doc:`/analytics` |
:doc:`/pull-requests` |
:doc:`/build-notifications` |
:doc:`/user-defined-redirects`
:doc:`/user-defined-redirects` |
:doc:`/security-log`

* **Connecting with GitHub, BitBucket, or GitLab**:
:doc:`Connecting your VCS account </connected-accounts>`
Expand Down Expand Up @@ -114,6 +115,7 @@ and some of the core features of Read the Docs.
/analytics
/pull-requests
/build-notifications
/security-log

/connected-accounts

Expand Down
47 changes: 47 additions & 0 deletions docs/security-log.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
Security Log
============

Security logs allow you to see what has happened recently in your organization or account.
We store the IP address and the browser's User-Agent_ on each event,
so that you can confirm this access was from the intended person.

.. _User-Agent: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent

User security log
-----------------

We store user security logs from the last 90 days, and track the following events:
Copy link
Member

@ericholscher ericholscher Oct 28, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this vary by plan on .com? If so, we should probably put this in a small table comparing each site. I dislike having the tabs where you have to click to see the Commercial one, so a small table seems best?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User securtiy logs are 90 days for both, we could respect the max period set by the organizations they belong on .com, but I have some questions if that happens, should we show those type of logs to org owners as well? #8620 (comment)


- Authentication on the dashboard
- Authentication on documentation pages (:doc:`/commercial/index` only)

Authentication failures and successes are both tracked.

To access your logs:

- Click on :guilabel:`Username` dropdown
- Click on :guilabel:`Settings`
- Click on :guilabel:`Security Log`

Organization security log
-------------------------

.. note::

This feature exists only on :doc:`/commercial/index`.

We store logs according to your plan,
check our `pricing page <https://readthedocs.com/pricing/>`__ for more details.
We track the following events:

- Authentication on documentation pages from your organization
- User access to every documentation page from your organization (**Enterprise plans only**)

Authentication failures and successes are both tracked.

To access your organization logs:

- Click on :guilabel:`Organizations` from your user dropdown
- Click on your organization
- Click on :guilabel:`Settings`
- Click on :guilabel:`Security Log`
25 changes: 23 additions & 2 deletions readthedocs/audit/filters.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Filters used in our views."""

from django.utils.translation import ugettext_lazy as _
from django_filters import CharFilter, ChoiceFilter, FilterSet
from django_filters import (
CharFilter,
ChoiceFilter,
DateFromToRangeFilter,
FilterSet,
)

from readthedocs.audit.models import AuditLog

Expand All @@ -18,7 +23,23 @@ class UserSecurityLogFilter(FilterSet):
(AuditLog.AUTHN_FAILURE, _('Authentication failure')),
],
)
date = DateFromToRangeFilter(field_name='created')

class Meta:
model = AuditLog
fields = ['ip', 'project', 'action']
fields = []
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this wasn't needed, as these values are defined as attributes, but you still need to declare fields



class OrganizationSecurityLogFilter(UserSecurityLogFilter):

action = ChoiceFilter(
field_name='action',
lookup_expr='exact',
choices=[
(AuditLog.AUTHN, _('Authentication success')),
(AuditLog.AUTHN_FAILURE, _('Authentication failure')),
(AuditLog.PAGEVIEW, _('Page view')),
(AuditLog.DOWNLOAD, _('Download')),
],
)
user = CharFilter(field_name='log_user_username', lookup_expr='exact')
18 changes: 18 additions & 0 deletions readthedocs/audit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,5 +197,23 @@ def save(self, **kwargs):
self.log_organization_slug = organization.slug
super().save(**kwargs)

def auth_backend_display(self):
"""
Get a string representation for backends that aren't part of the normal login.
.. note::
The backends listed here are implemented on .com only.
"""
backend = self.auth_backend or ''
backend_displays = {
'TemporaryAccessTokenBackend': _('shared link'),
'TemporaryAccessPasswordBackend': _('shared password'),
}
for name, display in backend_displays.items():
if name in backend:
return display
return ''

def __str__(self):
return self.action
96 changes: 96 additions & 0 deletions readthedocs/audit/templates/audit/list_logs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
{% load i18n %}

{% comment %}
If `omit_user` is given, the username attached to the log isn't shown.
This is useful when listing logs for the same user.
{% endcomment %}

<div class="module-list">
<div class="module-list-wrapper">
<ul>
{% for log in object_list %}
<li class="module-item">
{% if log.action == AuditLog.AUTHN %}
{% if omit_user %}
{% blocktrans trimmed with action=log.action method=log.auth_backend_display %}
<a href="?action={{ action }}" title="{{ method }}">Authenticated</a>
{% endblocktrans %}
{% elif log.log_user_id %}
{% blocktrans trimmed with action=log.action user=log.log_user_username method=log.auth_backend_display %}
<a href="?user={{ user }}">
<code>{{ user }}</code>
</a>
<a href="?action={{ action }}" title="{{ method }}">authenticated</a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with action=log.action method=log.auth_backend_display %}
User <a href="?action={{ action }}" title="{{ method }}">authenticated</a>
{% endblocktrans %}
{% endif %}
{% elif log.action == AuditLog.AUTHN_FAILURE %}
{% blocktrans trimmed with action=log.action method=log.auth_backend_display %}
<a href="?action={{ action }}" title="{{ method }}">Authentication failed</a>
{% endblocktrans %}
{% elif log.action == AuditLog.PAGEVIEW %}
{% if log.log_user_id %}
{% blocktrans trimmed with action=log.action user=log.log_user_username path=log.resource %}
<a href="?user={{ user }}">
<code>{{ user }}</code>
</a>
<a href="?action={{ action }}" title="{{ path }}">visited</a> a page
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with action=log.action user=log.log_user_username path=log.resource %}
A user <a href="?action={{ action }}" title="{{ path }}">visited</a> a page
{% endblocktrans %}
{% endif %}
{% elif log.action == AuditLog.DOWNLOAD %}
{% if log.log_user_id %}
{% blocktrans trimmed with action=log.action user=log.log_user_username path=log.resource %}
<a href="?user={{ user }}">
<code>{{ user }}</code>
</a>
<a href="?action={{ action }}" title="{{ path }}">downloaded</a> a document
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with action=log.action user=log.log_user_username path=log.resource %}
A user <a href="?action={{ action }}" title="{{ path }}">downloaded</a> a document
{% endblocktrans %}
{% endif %}
{% endif %}

{% trans "from" %}

<a href="?ip={{ log.ip }}" title="{{ log.browser }}">
<code>{{ log.ip }}</code>
</a>

{# If the authentication was on a doc domain. #}
{% if log.log_project_id %}
{% trans "on" %}
{% if log.project %}
<a href="{% url 'projects_detail' log.log_project_slug %}">
<code>{{ log.log_project_slug }}</code>
</a>
{% else %}
{# The original project has been deleted, don't link to it. #}
<code title="{{ log.resource }}">{{ log.log_project_slug }}</code>
{% endif %}
{% endif %}

<span class="quiet right" title="{{ log.created|date:"DATETIME_FORMAT" }}">
{% blocktrans trimmed with log.created|timesince as date %}
{{ date }} ago
{% endblocktrans %}
</span>
</li>
{% empty %}
<li class="module-item">
<p class="quiet">
{% trans 'No activity logged yet' %}
</p>
</li>
{% endfor %}
</ul>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ul>
<li class="{% block organization-admin-details %}{% endblock %}"><a href="{% url 'organization_edit' organization.slug %}">{% trans "Details" %}</a></li>
<li class="{% block organization-admin-owners %}{% endblock %}"><a href="{% url 'organization_owners' organization.slug %}">{% trans "Owners" %}</a></li>
<li class="{% block organization-admin-security-log %}{% endblock %}"><a href="{% url 'organization_security_log' organization.slug %}">{% trans "Security Log" %}</a></li>
</ul>
<div>
<h2>{% block edit_content_header %}{% endblock %}</h2>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% extends "organizations/admin/base.html" %}

{% load i18n %}
{% load pagination_tags %}

{% block title %}{% trans "Organization Security Log" %}{% endblock %}

{% block organization-admin-security-log %}active{% endblock %}

{% block edit_content_header %} {% trans "Organization Security Log" %} {% endblock %}

{% block edit_content %}

{% if not enabled %}
{% include 'projects/includes/feature_disabled.html' with organization=organization %}
Copy link
Member

@ericholscher ericholscher Oct 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be passing in something like plan=Pro in order to clarify what plan is required?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could pass the feature type and check from the other template what plan is required?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, not a huge priority though. 👍

{% endif %}

<p class="help_text">
{% blocktrans trimmed with docs_url='https://docs.readthedocs.io/page/security-log.html#organization-security-log' %}
The <a href="{{ docs_url }}">organization security log</a> allows you to see what has happened recently in your organization.
{% endblocktrans %}

{% if enabled %}
{% if days_limit and days_limit > 0 %}
{% blocktrans trimmed with days_limit=days_limit %}
Showing logs from the last {{ days_limit }} days.
You can upgrade your plan to increase the time period that is stored.
{% endblocktrans %}
{% else %}
{% trans "Showing logs from all time." %}
{% endif %}
{% endif %}
</p>

{% autopaginate object_list 15 %}

<div class="module">
<div class="button-bar">
<ul>
<li>
<a class="button"
href="?download=true&{{ request.GET.urlencode }}">
{% trans "Download" %}
</a>
</li>
</ul>
</div>

{% include "audit/list_logs.html" with omit_user=False %}
</div>
{% paginate %}
{% endblock %}
Loading