diff --git a/authentik/admin/api/version_history.py b/authentik/admin/api/version_history.py new file mode 100644 index 000000000000..fb54e3563c94 --- /dev/null +++ b/authentik/admin/api/version_history.py @@ -0,0 +1,33 @@ +from rest_framework.permissions import IsAdminUser +from rest_framework.viewsets import ReadOnlyModelViewSet + +from authentik.admin.models import VersionHistory +from authentik.core.api.utils import ModelSerializer + + +class VersionHistorySerializer(ModelSerializer): + """VersionHistory Serializer""" + + class Meta: + model = VersionHistory + fields = [ + "id", + "timestamp", + "version", + "build", + ] + + +class VersionHistoryViewSet(ReadOnlyModelViewSet): + """VersionHistory Viewset""" + + queryset = VersionHistory.objects.all() + serializer_class = VersionHistorySerializer + permission_classes = [IsAdminUser] + filterset_fields = [ + "version", + "build", + ] + search_fields = ["version", "build"] + ordering = ["-timestamp"] + pagination_class = None diff --git a/authentik/admin/models.py b/authentik/admin/models.py new file mode 100644 index 000000000000..ee4a2561b617 --- /dev/null +++ b/authentik/admin/models.py @@ -0,0 +1,22 @@ +"""authentik admin models""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class VersionHistory(models.Model): + id = models.BigAutoField(primary_key=True) + timestamp = models.DateTimeField() + version = models.TextField() + build = models.TextField() + + class Meta: + managed = False + db_table = "authentik_version_history" + ordering = ("-timestamp",) + verbose_name = _("Version history") + verbose_name_plural = _("Version history") + default_permissions = [] + + def __str__(self): + return f"{self.version}.{self.build} ({self.timestamp})" diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py index 2be8af575633..51bdd4eca576 100644 --- a/authentik/admin/urls.py +++ b/authentik/admin/urls.py @@ -6,6 +6,7 @@ from authentik.admin.api.metrics import AdministrationMetricsViewSet from authentik.admin.api.system import SystemView from authentik.admin.api.version import VersionView +from authentik.admin.api.version_history import VersionHistoryViewSet from authentik.admin.api.workers import WorkerView api_urlpatterns = [ @@ -17,6 +18,7 @@ name="admin_metrics", ), path("admin/version/", VersionView.as_view(), name="admin_version"), + ("admin/version/history", VersionHistoryViewSet, "version_history"), path("admin/workers/", WorkerView.as_view(), name="admin_workers"), path("admin/system/", SystemView.as_view(), name="admin_system"), ] diff --git a/lifecycle/migrate.py b/lifecycle/migrate.py index ea22aa8fb734..337e48f2c3fc 100755 --- a/lifecycle/migrate.py +++ b/lifecycle/migrate.py @@ -84,7 +84,9 @@ def run_migrations(): curr = conn.cursor() try: wait_for_lock(curr) - for migration_path in Path(__file__).parent.absolute().glob("system_migrations/*.py"): + for migration_path in sorted( + Path(__file__).parent.absolute().glob("system_migrations/*.py") + ): spec = spec_from_file_location("lifecycle.system_migrations", migration_path) if not spec: continue diff --git a/lifecycle/system_migrations/version_history_create.py b/lifecycle/system_migrations/version_history_create.py new file mode 100644 index 000000000000..258c645139c3 --- /dev/null +++ b/lifecycle/system_migrations/version_history_create.py @@ -0,0 +1,24 @@ +# flake8: noqa +from lifecycle.migrate import BaseMigration + + +class Migration(BaseMigration): + def needs_migration(self) -> bool: + self.cur.execute( + "SELECT * FROM information_schema.tables WHERE table_name = 'authentik_version_history';" + ) + return not bool(self.cur.rowcount) + + def run(self): + self.cur.execute( + """ + BEGIN TRANSACTION; + CREATE TABLE IF NOT EXISTS authentik_version_history ( + id BIGSERIAL PRIMARY KEY, + "timestamp" timestamp with time zone NOT NULL, + version text NOT NULL, + build text NOT NULL + ); + COMMIT; + """ + ) diff --git a/lifecycle/system_migrations/version_history_update.py b/lifecycle/system_migrations/version_history_update.py new file mode 100644 index 000000000000..1fb38eabb864 --- /dev/null +++ b/lifecycle/system_migrations/version_history_update.py @@ -0,0 +1,38 @@ +# flake8: noqa +from lifecycle.migrate import BaseMigration +from datetime import datetime + +from authentik import __version__, get_build_hash + + +class Migration(BaseMigration): + def needs_migration(self) -> bool: + self.cur.execute( + """ + SELECT * FROM authentik_version_history + WHERE version = %s AND build = %s + ORDER BY "timestamp" DESC + LIMIT 1 + """, + (__version__, get_build_hash()), + ) + return not bool(self.cur.rowcount) + + def run(self): + self.cur.execute( + """ + INSERT INTO authentik_version_history ("timestamp", version, build) + VALUES (%s, %s, %s) + """, + (datetime.now(), __version__, get_build_hash()), + ) + self.cur.execute( + """ + DELETE FROM authentik_version_history WHERE id NOT IN ( + SELECT id FROM authentik_version_history + ORDER BY "timestamp" DESC + LIMIT 1000 + ) + """ + ) + self.con.commit() diff --git a/schema.yml b/schema.yml index 507f6c1ee84f..f8b5472862b7 100644 --- a/schema.yml +++ b/schema.yml @@ -263,6 +263,90 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /admin/version/history/: + get: + operationId: admin_version_history_list + description: VersionHistory Viewset + parameters: + - in: query + name: build + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: version + schema: + type: string + tags: + - admin + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/VersionHistory' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /admin/version/history/{id}/: + get: + operationId: admin_version_history_retrieve + description: VersionHistory Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Version history. + required: true + tags: + - admin + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/VersionHistory' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /admin/workers/: get: operationId: admin_workers_retrieve @@ -53109,6 +53193,25 @@ components: - version_current - version_latest - version_latest_valid + VersionHistory: + type: object + description: VersionHistory Serializer + properties: + id: + type: integer + readOnly: true + timestamp: + type: string + format: date-time + version: + type: string + build: + type: string + required: + - build + - id + - timestamp + - version WebAuthnDevice: type: object description: Serializer for WebAuthn authenticator devices