mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
36 Commits
2025-12/fi
...
packages/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a56a5b9c | ||
|
|
73afaed115 | ||
|
|
8b758402c0 | ||
|
|
050c9c31af | ||
|
|
921269f990 | ||
|
|
87732a413c | ||
|
|
8cfe83bd47 | ||
|
|
42c4fee053 | ||
|
|
26cfbe67f3 | ||
|
|
2a17024afc | ||
|
|
c557b55e0e | ||
|
|
f56e354e38 | ||
|
|
c50c2b0e0c | ||
|
|
662124cac9 | ||
|
|
3d671a901b | ||
|
|
a7fb031b64 | ||
|
|
2818b0bbdf | ||
|
|
60075e39fb | ||
|
|
c112f702b3 | ||
|
|
42b3323b3d | ||
|
|
78380831de | ||
|
|
8b5195aeff | ||
|
|
d762e38027 | ||
|
|
e427cb611e | ||
|
|
20dbcf2e7b | ||
|
|
d93138f790 | ||
|
|
9ef7f706e9 | ||
|
|
627176ab7e | ||
|
|
069622aea4 | ||
|
|
3da523cbd5 | ||
|
|
126310138d | ||
|
|
9f1e55fbe6 | ||
|
|
5997cda48b | ||
|
|
fbe8028b08 | ||
|
|
1df84d68dd | ||
|
|
7f8527461a |
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -21,7 +21,7 @@ runs:
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v5
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
|
||||
6
.github/workflows/ci-api-docs.yml
vendored
6
.github/workflows/ci-api-docs.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
- uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
|
||||
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/website/api/.docusaurus
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: npm run build -w api
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v5
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
|
||||
2
.github/workflows/ci-main.yml
vendored
2
.github/workflows/ci-main.yml
vendored
@@ -201,7 +201,7 @@ jobs:
|
||||
run: |
|
||||
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
|
||||
15
Makefile
15
Makefile
@@ -9,6 +9,13 @@ NPM_VERSION = $(shell python -m scripts.generate_semver)
|
||||
PY_SOURCES = authentik packages tests scripts lifecycle .github
|
||||
DOCKER_IMAGE ?= "authentik:test"
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
SED_INPLACE = sed -i ''
|
||||
else
|
||||
SED_INPLACE = sed -i
|
||||
endif
|
||||
|
||||
GEN_API_TS = gen-ts-api
|
||||
GEN_API_PY = gen-py-api
|
||||
GEN_API_GO = gen-go-api
|
||||
@@ -119,8 +126,8 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
|
||||
ifndef version
|
||||
$(error Usage: make bump version=20xx.xx.xx )
|
||||
endif
|
||||
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||
$(SED_INPLACE) 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||
$(MAKE) gen-build gen-compose aws-cfn
|
||||
npm version --no-git-tag-version --allow-same-version $(version)
|
||||
cd ${PWD}/web && npm version --no-git-tag-version --allow-same-version $(version)
|
||||
@@ -155,8 +162,8 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a
|
||||
/local/schema-old.yml \
|
||||
/local/schema.yml
|
||||
rm schema-old.yml
|
||||
sed -i 's/{/{/g' diff.md
|
||||
sed -i 's/}/}/g' diff.md
|
||||
$(SED_INPLACE) 's/{/{/g' diff.md
|
||||
$(SED_INPLACE) 's/}/}/g' diff.md
|
||||
npx prettier --write diff.md
|
||||
|
||||
gen-clean-ts: ## Remove generated API client for TypeScript
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.12.0-rc1"
|
||||
VERSION = "2026.2.0-rc1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -240,7 +240,9 @@ class FileUsedByView(APIView):
|
||||
for field in fields:
|
||||
q |= Q(**{field: params.get("name")})
|
||||
|
||||
objs = get_objects_for_user(request.user, f"{app}.view_{model_name}", model)
|
||||
objs = get_objects_for_user(
|
||||
request.user, f"{app}.view_{model_name}", model.objects.all()
|
||||
)
|
||||
objs = objs.filter(q)
|
||||
for obj in objs:
|
||||
serializer = UsedBySerializer(
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class AuthentikFilesConfig(ManagedAppConfig):
|
||||
@@ -11,20 +6,3 @@ class AuthentikFilesConfig(ManagedAppConfig):
|
||||
label = "authentik_admin_files"
|
||||
verbose_name = "authentik Files"
|
||||
default = True
|
||||
|
||||
@ManagedAppConfig.reconcile_global
|
||||
def check_for_media_mount(self):
|
||||
if settings.TEST:
|
||||
return
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
if (
|
||||
CONFIG.get("storage.media.backend", CONFIG.get("storage.backend", "file")) == "file"
|
||||
and Path("/media").exists()
|
||||
):
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="/media has been moved to /data/media. "
|
||||
"Check the release notes for migration steps.",
|
||||
).save()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
"""Test S3 backend functionality"""
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import shutil
|
||||
import socket
|
||||
from tempfile import mkdtemp
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from authentik.admin.files.backends.s3 import S3Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG, UNSET
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
S3_TEST_ENDPOINT = "http://localhost:8020"
|
||||
|
||||
|
||||
def s3_test_server_available() -> bool:
|
||||
"""Check if the S3 test server is reachable."""
|
||||
|
||||
parsed = urlparse(S3_TEST_ENDPOINT)
|
||||
try:
|
||||
with socket.create_connection((parsed.hostname, parsed.port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class FileTestFileBackendMixin:
|
||||
def setUp(self):
|
||||
@@ -57,7 +72,7 @@ class FileTestS3BackendMixin:
|
||||
for key in s3_config_keys:
|
||||
self.original_media_s3_settings[key] = CONFIG.get(f"storage.media.s3.{key}", UNSET)
|
||||
self.media_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.media.s3.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.media.s3.endpoint", S3_TEST_ENDPOINT)
|
||||
CONFIG.set("storage.media.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
|
||||
@@ -70,7 +85,7 @@ class FileTestS3BackendMixin:
|
||||
for key in s3_config_keys:
|
||||
self.original_reports_s3_settings[key] = CONFIG.get(f"storage.reports.s3.{key}", UNSET)
|
||||
self.reports_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.reports.s3.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.reports.s3.endpoint", S3_TEST_ENDPOINT)
|
||||
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)
|
||||
|
||||
@@ -180,10 +180,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
|
||||
def _filter_applications_with_launch_url(
|
||||
self, applications: QuerySet[Application]
|
||||
self, paginated_apps: QuerySet[Application]
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
for app in applications:
|
||||
for app in paginated_apps:
|
||||
if app.get_launch_url():
|
||||
applications.append(app)
|
||||
return applications
|
||||
|
||||
@@ -246,11 +246,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
]
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import BoolField, StrField
|
||||
|
||||
from authentik.enterprise.search.fields import (
|
||||
JSONSearchField,
|
||||
)
|
||||
from akql.schema import BoolField, JSONSearchField, StrField
|
||||
|
||||
return [
|
||||
StrField(Group, "name"),
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
@@ -145,12 +144,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
owner_field = "user"
|
||||
rbac_allow_create_without_perm = True
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user if self.request else get_anonymous_user()
|
||||
if user.is_superuser:
|
||||
return super().get_queryset()
|
||||
return super().get_queryset().filter(user=user.pk)
|
||||
|
||||
def perform_create(self, serializer: TokenSerializer):
|
||||
if not self.request.user.is_superuser:
|
||||
instance = serializer.save(
|
||||
|
||||
@@ -504,12 +504,7 @@ class UserViewSet(
|
||||
]
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import BoolField, StrField
|
||||
|
||||
from authentik.enterprise.search.fields import (
|
||||
ChoiceSearchField,
|
||||
JSONSearchField,
|
||||
)
|
||||
from akql.schema import BoolField, ChoiceSearchField, JSONSearchField, StrField
|
||||
|
||||
return [
|
||||
StrField(User, "username"),
|
||||
|
||||
@@ -183,16 +183,16 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(len(body["results"]), 1)
|
||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||
|
||||
def test_list_admin(self):
|
||||
"""Test Token List (Test with admin auth)"""
|
||||
def test_list_with_permission(self):
|
||||
"""Test Token List (Test with `view_token` permission)"""
|
||||
Token.objects.all().delete()
|
||||
self.client.force_login(self.admin)
|
||||
token_should: Token = Token.objects.create(
|
||||
identifier="test", expiring=False, user=self.user
|
||||
)
|
||||
token_should_not: Token = Token.objects.create(
|
||||
identifier="test-2", expiring=False, user=get_anonymous_user()
|
||||
)
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_token")
|
||||
response = self.client.get(reverse("authentik_api:token-list"))
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Crypto API Views"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.x509 import load_pem_x509_certificate
|
||||
@@ -15,14 +13,12 @@ from drf_spectacular.utils import (
|
||||
OpenApiParameter,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
extend_schema_field,
|
||||
)
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import (
|
||||
CharField,
|
||||
ChoiceField,
|
||||
DateTimeField,
|
||||
IntegerField,
|
||||
SerializerMethodField,
|
||||
)
|
||||
@@ -51,59 +47,15 @@ LOGGER = get_logger()
|
||||
class CertificateKeyPairSerializer(ModelSerializer):
|
||||
"""CertificateKeyPair Serializer"""
|
||||
|
||||
fingerprint_sha256 = SerializerMethodField()
|
||||
fingerprint_sha1 = SerializerMethodField()
|
||||
|
||||
cert_expiry = SerializerMethodField()
|
||||
cert_subject = SerializerMethodField()
|
||||
private_key_available = SerializerMethodField()
|
||||
key_type = SerializerMethodField()
|
||||
|
||||
certificate_download_url = SerializerMethodField()
|
||||
private_key_download_url = SerializerMethodField()
|
||||
|
||||
@property
|
||||
def _should_include_details(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_details", "true")).lower() == "true"
|
||||
|
||||
def get_fingerprint_sha256(self, instance: CertificateKeyPair) -> str | None:
|
||||
"Get certificate Hash (SHA256)"
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.fingerprint_sha256
|
||||
|
||||
def get_fingerprint_sha1(self, instance: CertificateKeyPair) -> str | None:
|
||||
"Get certificate Hash (SHA1)"
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.fingerprint_sha1
|
||||
|
||||
def get_cert_expiry(self, instance: CertificateKeyPair) -> datetime | None:
|
||||
"Get certificate expiry"
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return DateTimeField().to_representation(instance.certificate.not_valid_after_utc)
|
||||
|
||||
def get_cert_subject(self, instance: CertificateKeyPair) -> str | None:
|
||||
"""Get certificate subject as full rfc4514"""
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.certificate.subject.rfc4514_string()
|
||||
|
||||
def get_private_key_available(self, instance: CertificateKeyPair) -> bool:
|
||||
"""Show if this keypair has a private key configured or not"""
|
||||
return instance.key_data != "" and instance.key_data is not None
|
||||
|
||||
@extend_schema_field(ChoiceField(choices=KeyType.choices, allow_null=True))
|
||||
def get_key_type(self, instance: CertificateKeyPair) -> str | None:
|
||||
"""Get the key algorithm type from the certificate's public key"""
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.key_type
|
||||
|
||||
def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
|
||||
"""Get URL to download certificate"""
|
||||
return (
|
||||
@@ -175,6 +127,11 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
||||
"managed": {"read_only": True},
|
||||
"key_data": {"write_only": True},
|
||||
"certificate_data": {"write_only": True},
|
||||
"fingerprint_sha256": {"read_only": True},
|
||||
"fingerprint_sha1": {"read_only": True},
|
||||
"cert_expiry": {"read_only": True},
|
||||
"cert_subject": {"read_only": True},
|
||||
"key_type": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
@@ -216,17 +173,12 @@ class CertificateKeyPairFilter(FilterSet):
|
||||
return queryset.exclude(key_data__exact="")
|
||||
|
||||
def filter_key_type(self, queryset, name, value): # pragma: no cover
|
||||
"""Filter certificates by key type using the public key from the certificate"""
|
||||
"""Filter certificates by key type using the stored database field"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# value is a list of KeyType enum values from MultipleChoiceFilter
|
||||
filtered_pks = []
|
||||
for cert in queryset:
|
||||
if cert.key_type in value:
|
||||
filtered_pks.append(cert.pk)
|
||||
|
||||
return queryset.filter(pk__in=filtered_pks)
|
||||
return queryset.filter(key_type__in=value)
|
||||
|
||||
class Meta:
|
||||
model = CertificateKeyPair
|
||||
@@ -263,7 +215,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
"Can be specified multiple times (e.g. '?key_type=rsa&key_type=ec')"
|
||||
),
|
||||
),
|
||||
OpenApiParameter("include_details", bool, default=True),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-09 06:22
|
||||
|
||||
from hashlib import md5
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.x509 import load_pem_x509_certificate
|
||||
from django.db import migrations, models
|
||||
|
||||
from authentik.crypto.signals import extract_certificate_metadata
|
||||
|
||||
|
||||
def backfill_certificate_metadata(apps, schema_editor): # noqa: ARG001
|
||||
"""Backfill certificate metadata and kid for existing records."""
|
||||
|
||||
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
||||
|
||||
for cert in CertificateKeyPair.objects.all():
|
||||
updated_fields = []
|
||||
|
||||
if cert.certificate_data:
|
||||
try:
|
||||
certificate = load_pem_x509_certificate(
|
||||
cert.certificate_data.encode("utf-8"), default_backend()
|
||||
)
|
||||
metadata = extract_certificate_metadata(certificate)
|
||||
|
||||
cert.key_type = metadata["key_type"]
|
||||
cert.cert_expiry = metadata["cert_expiry"]
|
||||
cert.cert_subject = metadata["cert_subject"]
|
||||
cert.fingerprint_sha256 = metadata["fingerprint_sha256"]
|
||||
cert.fingerprint_sha1 = metadata["fingerprint_sha1"]
|
||||
updated_fields.extend(
|
||||
[
|
||||
"key_type",
|
||||
"cert_expiry",
|
||||
"cert_subject",
|
||||
"fingerprint_sha256",
|
||||
"fingerprint_sha1",
|
||||
]
|
||||
)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
# Backfill kid with MD5 for backwards compatibility
|
||||
if cert.key_data:
|
||||
cert.kid = md5(cert.key_data.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
updated_fields.append("kid")
|
||||
|
||||
if updated_fields:
|
||||
cert.save(update_fields=updated_fields)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0005_alter_certificatekeypair_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="cert_expiry",
|
||||
field=models.DateTimeField(blank=True, help_text="Certificate expiry date", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="cert_subject",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="Certificate subject as RFC4514 string", null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="fingerprint_sha1",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="SHA1 fingerprint of the certificate",
|
||||
max_length=59,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="fingerprint_sha256",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="SHA256 fingerprint of the certificate",
|
||||
max_length=95,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="key_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("rsa", "RSA"),
|
||||
("ec", "Elliptic Curve"),
|
||||
("dsa", "DSA"),
|
||||
("ed25519", "Ed25519"),
|
||||
("ed448", "Ed448"),
|
||||
],
|
||||
help_text="Key algorithm type detected from the certificate's public key",
|
||||
max_length=16,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="kid",
|
||||
field=models.CharField(
|
||||
blank=True, help_text="Key ID generated from private key", max_length=128, null=True
|
||||
),
|
||||
),
|
||||
migrations.RunPython(backfill_certificate_metadata, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -1,7 +1,8 @@
|
||||
"""authentik crypto models"""
|
||||
|
||||
from base64 import urlsafe_b64encode
|
||||
from binascii import hexlify
|
||||
from hashlib import md5
|
||||
from hashlib import md5, sha512
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
from textwrap import wrap
|
||||
from uuid import uuid4
|
||||
@@ -47,6 +48,39 @@ def fingerprint_sha256(cert: Certificate) -> str:
|
||||
return hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8")
|
||||
|
||||
|
||||
def detect_key_type(certificate: Certificate) -> str | None:
|
||||
"""Detect the key algorithm type by parsing the certificate's public key"""
|
||||
try:
|
||||
public_key = certificate.public_key()
|
||||
if isinstance(public_key, RSAPublicKey):
|
||||
return KeyType.RSA
|
||||
if isinstance(public_key, EllipticCurvePublicKey):
|
||||
return KeyType.EC
|
||||
if isinstance(public_key, DSAPublicKey):
|
||||
return KeyType.DSA
|
||||
if isinstance(public_key, Ed25519PublicKey):
|
||||
return KeyType.ED25519
|
||||
if isinstance(public_key, Ed448PublicKey):
|
||||
return KeyType.ED448
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("Failed to detect key type", exc=exc)
|
||||
return None
|
||||
|
||||
|
||||
def generate_key_id(key_data: str) -> str:
|
||||
"""Generate Key ID using SHA512 + urlsafe_b64encode."""
|
||||
if not key_data:
|
||||
return ""
|
||||
return urlsafe_b64encode(sha512(key_data.encode("utf-8")).digest()).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def generate_key_id_legacy(key_data: str) -> str:
|
||||
"""Generate Key ID using MD5 (legacy format for backwards compatibility)."""
|
||||
if not key_data:
|
||||
return ""
|
||||
return md5(key_data.encode("utf-8")).hexdigest() # nosec
|
||||
|
||||
|
||||
class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
"""CertificateKeyPair that can be used for signing or encrypting if `key_data`
|
||||
is set, otherwise it can be used to verify remote data."""
|
||||
@@ -62,6 +96,41 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
key_type = models.CharField(
|
||||
max_length=16,
|
||||
choices=KeyType.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Key algorithm type detected from the certificate's public key"),
|
||||
)
|
||||
cert_expiry = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Certificate expiry date"),
|
||||
)
|
||||
cert_subject = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Certificate subject as RFC4514 string"),
|
||||
)
|
||||
fingerprint_sha256 = models.CharField(
|
||||
max_length=95,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("SHA256 fingerprint of the certificate"),
|
||||
)
|
||||
fingerprint_sha1 = models.CharField(
|
||||
max_length=59,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("SHA1 fingerprint of the certificate"),
|
||||
)
|
||||
kid = models.CharField(
|
||||
max_length=128,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Key ID generated from private key"),
|
||||
)
|
||||
|
||||
_cert: Certificate | None = None
|
||||
_private_key: PrivateKeyTypes | None = None
|
||||
@@ -106,41 +175,6 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
return None
|
||||
return self._private_key
|
||||
|
||||
@property
|
||||
def fingerprint_sha256(self) -> str:
|
||||
"""Get SHA256 Fingerprint of certificate_data"""
|
||||
return fingerprint_sha256(self.certificate)
|
||||
|
||||
@property
|
||||
def fingerprint_sha1(self) -> str:
|
||||
"""Get SHA1 Fingerprint of certificate_data"""
|
||||
return hexlify(self.certificate.fingerprint(hashes.SHA1()), ":").decode("utf-8") # nosec
|
||||
|
||||
@property
|
||||
def kid(self):
|
||||
"""Get Key ID used for JWKS"""
|
||||
return (
|
||||
md5(self.key_data.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
if self.key_data
|
||||
else ""
|
||||
) # nosec
|
||||
|
||||
@property
|
||||
def key_type(self) -> str | None:
|
||||
"""Get the key algorithm type from the certificate's public key"""
|
||||
public_key = self.certificate.public_key()
|
||||
if isinstance(public_key, RSAPublicKey):
|
||||
return KeyType.RSA
|
||||
if isinstance(public_key, EllipticCurvePublicKey):
|
||||
return KeyType.EC
|
||||
if isinstance(public_key, DSAPublicKey):
|
||||
return KeyType.DSA
|
||||
if isinstance(public_key, Ed25519PublicKey):
|
||||
return KeyType.ED25519
|
||||
if isinstance(public_key, Ed448PublicKey):
|
||||
return KeyType.ED448
|
||||
return None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Certificate-Key Pair {self.name}"
|
||||
|
||||
|
||||
70
authentik/crypto/signals.py
Normal file
70
authentik/crypto/signals.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""authentik crypto signals"""
|
||||
|
||||
from binascii import hexlify
|
||||
from datetime import datetime
|
||||
from ssl import CertificateError
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.x509 import Certificate
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.crypto.models import (
|
||||
CertificateKeyPair,
|
||||
detect_key_type,
|
||||
fingerprint_sha256,
|
||||
generate_key_id,
|
||||
generate_key_id_legacy,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def extract_certificate_metadata(certificate: Certificate) -> dict[str, str | datetime]:
|
||||
"""Extract all metadata fields from a certificate."""
|
||||
metadata = {}
|
||||
|
||||
try:
|
||||
metadata["key_type"] = detect_key_type(certificate)
|
||||
metadata["cert_expiry"] = certificate.not_valid_after_utc
|
||||
metadata["cert_subject"] = certificate.subject.rfc4514_string()
|
||||
metadata["fingerprint_sha256"] = fingerprint_sha256(certificate)
|
||||
metadata["fingerprint_sha1"] = hexlify(
|
||||
certificate.fingerprint(hashes.SHA1()), ":" # nosec
|
||||
).decode("utf-8")
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise CertificateError(f"Invalid certificate metadata: {exc}") from exc
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
@receiver(pre_save, sender="authentik_crypto.CertificateKeyPair")
|
||||
def certificate_key_pair_pre_save(
|
||||
sender: type[CertificateKeyPair], instance: CertificateKeyPair, **_
|
||||
):
|
||||
"""Automatically populate certificate metadata fields before saving"""
|
||||
|
||||
# Only extract metadata if certificate_data is present
|
||||
if not instance.certificate_data:
|
||||
return
|
||||
|
||||
try:
|
||||
metadata = extract_certificate_metadata(instance.certificate)
|
||||
except (CertificateError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("Failed to extract certificate metadata", exc=exc)
|
||||
return
|
||||
|
||||
instance.key_type = metadata["key_type"]
|
||||
instance.cert_expiry = metadata["cert_expiry"]
|
||||
instance.cert_subject = metadata["cert_subject"]
|
||||
instance.fingerprint_sha256 = metadata["fingerprint_sha256"]
|
||||
instance.fingerprint_sha1 = metadata["fingerprint_sha1"]
|
||||
|
||||
# Generate kid if not set, or regenerate if key_data has changed
|
||||
# Preserve existing kid (MD5 or SHA512) if it matches the current key_data
|
||||
if instance.key_data:
|
||||
new_kid = generate_key_id(instance.key_data)
|
||||
legacy_kid = generate_key_id_legacy(instance.key_data)
|
||||
if instance.kid not in (new_kid, legacy_kid):
|
||||
instance.kid = new_kid
|
||||
@@ -20,7 +20,7 @@ from authentik.core.tests.utils import (
|
||||
)
|
||||
from authentik.crypto.api import CertificateKeyPairSerializer
|
||||
from authentik.crypto.builder import CertificateBuilder
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.crypto.models import CertificateKeyPair, generate_key_id, generate_key_id_legacy
|
||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
@@ -173,21 +173,24 @@ class TestCrypto(APITestCase):
|
||||
self.assertEqual(api_cert["fingerprint_sha1"], cert.fingerprint_sha1)
|
||||
self.assertEqual(api_cert["fingerprint_sha256"], cert.fingerprint_sha256)
|
||||
|
||||
def test_list_without_details(self):
|
||||
"""Test API List (no details)"""
|
||||
def test_list_always_includes_details(self):
|
||||
"""Test API List always includes certificate details"""
|
||||
cert = create_test_cert()
|
||||
self.client.force_login(create_test_admin_user())
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-list",
|
||||
),
|
||||
data={"name": cert.name, "include_details": False},
|
||||
data={"name": cert.name},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
api_cert = [x for x in body["results"] if x["name"] == cert.name][0]
|
||||
self.assertEqual(api_cert["fingerprint_sha1"], None)
|
||||
self.assertEqual(api_cert["fingerprint_sha256"], None)
|
||||
# All details should now always be included
|
||||
self.assertEqual(api_cert["fingerprint_sha1"], cert.fingerprint_sha1)
|
||||
self.assertEqual(api_cert["fingerprint_sha256"], cert.fingerprint_sha256)
|
||||
self.assertIsNotNone(api_cert["cert_expiry"])
|
||||
self.assertIsNotNone(api_cert["cert_subject"])
|
||||
|
||||
def test_certificate_download(self):
|
||||
"""Test certificate export (download)"""
|
||||
@@ -426,3 +429,114 @@ class TestCrypto(APITestCase):
|
||||
self.assertEqual(
|
||||
1, final_count, "Should not create duplicate cert for same private key"
|
||||
)
|
||||
|
||||
def test_metadata_extraction_with_cert_and_key(self):
|
||||
"""Test that metadata is extracted when creating keypair with certificate and key"""
|
||||
cert = create_test_cert()
|
||||
|
||||
# Verify all metadata fields are populated
|
||||
self.assertIsNotNone(cert.key_type)
|
||||
self.assertIsNotNone(cert.cert_expiry)
|
||||
self.assertIsNotNone(cert.cert_subject)
|
||||
self.assertIsNotNone(cert.fingerprint_sha256)
|
||||
self.assertIsNotNone(cert.fingerprint_sha1)
|
||||
|
||||
# Verify kid is generated using SHA512 for new records
|
||||
self.assertIsNotNone(cert.kid)
|
||||
self.assertEqual(cert.kid, generate_key_id(cert.key_data))
|
||||
|
||||
def test_metadata_extraction_without_key(self):
|
||||
"""Test that metadata is extracted when creating keypair without private key"""
|
||||
builder = CertificateBuilder(generate_id())
|
||||
builder.build(subject_alt_names=[], validity_days=3)
|
||||
|
||||
# Create keypair with only certificate, no key
|
||||
cert = CertificateKeyPair.objects.create(
|
||||
name=generate_id(),
|
||||
certificate_data=builder.certificate,
|
||||
key_data="",
|
||||
)
|
||||
|
||||
# Verify certificate metadata fields are populated
|
||||
self.assertIsNotNone(cert.key_type)
|
||||
self.assertIsNotNone(cert.cert_expiry)
|
||||
self.assertIsNotNone(cert.cert_subject)
|
||||
self.assertIsNotNone(cert.fingerprint_sha256)
|
||||
self.assertIsNotNone(cert.fingerprint_sha1)
|
||||
|
||||
# Verify kid is empty when no key_data
|
||||
self.assertEqual(cert.kid, None)
|
||||
|
||||
def test_metadata_extraction_invalid_cert(self):
|
||||
"""Test that invalid certificate data doesn't crash, just skips metadata"""
|
||||
cert = CertificateKeyPair.objects.create(
|
||||
name=generate_id(),
|
||||
certificate_data="invalid certificate data",
|
||||
key_data="",
|
||||
)
|
||||
|
||||
# Verify metadata fields are None for invalid cert
|
||||
self.assertIsNone(cert.key_type)
|
||||
self.assertIsNone(cert.cert_expiry)
|
||||
self.assertIsNone(cert.cert_subject)
|
||||
self.assertIsNone(cert.fingerprint_sha256)
|
||||
self.assertIsNone(cert.fingerprint_sha1)
|
||||
self.assertIsNone(cert.kid)
|
||||
|
||||
def test_kid_legacy_preservation(self):
|
||||
"""Test that legacy MD5 kid is preserved when key_data hasn't changed"""
|
||||
cert = create_test_cert()
|
||||
|
||||
# Simulate a legacy MD5 kid (as if backfilled from old system)
|
||||
legacy_kid = generate_key_id_legacy(cert.key_data)
|
||||
CertificateKeyPair.objects.filter(pk=cert.pk).update(kid=legacy_kid)
|
||||
cert.refresh_from_db()
|
||||
self.assertEqual(cert.kid, legacy_kid)
|
||||
|
||||
# Save the cert again (e.g., name change) - kid should be preserved
|
||||
cert.name = generate_id()
|
||||
cert.save()
|
||||
cert.refresh_from_db()
|
||||
|
||||
self.assertEqual(cert.kid, legacy_kid)
|
||||
|
||||
def test_kid_regenerated_on_key_change(self):
|
||||
"""Test that kid is regenerated when key_data changes"""
|
||||
cert = create_test_cert()
|
||||
original_kid = cert.kid
|
||||
|
||||
# Generate a new key and update the keypair
|
||||
builder = CertificateBuilder(generate_id())
|
||||
builder.build(subject_alt_names=[], validity_days=3)
|
||||
|
||||
cert.key_data = builder.private_key
|
||||
cert.certificate_data = builder.certificate
|
||||
cert.save()
|
||||
cert.refresh_from_db()
|
||||
|
||||
# Kid should be regenerated for the new key
|
||||
self.assertNotEqual(cert.kid, original_kid)
|
||||
self.assertEqual(cert.kid, generate_key_id(cert.key_data))
|
||||
|
||||
def test_kid_regenerated_on_key_change_from_legacy(self):
|
||||
"""Test that kid is regenerated from legacy MD5 when key_data changes"""
|
||||
cert = create_test_cert()
|
||||
|
||||
# Simulate a legacy MD5 kid
|
||||
legacy_kid = generate_key_id_legacy(cert.key_data)
|
||||
CertificateKeyPair.objects.filter(pk=cert.pk).update(kid=legacy_kid)
|
||||
cert.refresh_from_db()
|
||||
self.assertEqual(cert.kid, legacy_kid)
|
||||
|
||||
# Generate a new key and update the keypair
|
||||
builder = CertificateBuilder(generate_id())
|
||||
builder.build(subject_alt_names=[], validity_days=3)
|
||||
|
||||
cert.key_data = builder.private_key
|
||||
cert.certificate_data = builder.certificate
|
||||
cert.save()
|
||||
cert.refresh_from_db()
|
||||
|
||||
# Kid should now be SHA512 for the new key
|
||||
self.assertNotEqual(cert.kid, legacy_kid)
|
||||
self.assertEqual(cert.kid, generate_key_id(cert.key_data))
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""DjangoQL search"""
|
||||
|
||||
from collections import OrderedDict, defaultdict
|
||||
from collections.abc import Generator
|
||||
|
||||
from django.db import connection
|
||||
from django.db.models import Model, Q
|
||||
from djangoql.compat import text_type
|
||||
from djangoql.schema import StrField
|
||||
|
||||
|
||||
class JSONSearchField(StrField):
|
||||
"""JSON field for DjangoQL"""
|
||||
|
||||
model: Model
|
||||
|
||||
def __init__(self, model=None, name=None, nullable=None, suggest_nested=True):
|
||||
# Set this in the constructor to not clobber the type variable
|
||||
self.type = "relation"
|
||||
self.suggest_nested = suggest_nested
|
||||
super().__init__(model, name, nullable)
|
||||
|
||||
def get_lookup(self, path, operator, value):
|
||||
search = "__".join(path)
|
||||
op, invert = self.get_operator(operator)
|
||||
q = Q(**{f"{search}{op}": self.get_lookup_value(value)})
|
||||
return ~q if invert else q
|
||||
|
||||
def json_field_keys(self) -> Generator[tuple[str]]:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
WITH RECURSIVE "{self.name}_keys" AS (
|
||||
SELECT
|
||||
ARRAY[jsonb_object_keys("{self.name}")] AS key_path_array,
|
||||
"{self.name}" -> jsonb_object_keys("{self.name}") AS value
|
||||
FROM {self.model._meta.db_table}
|
||||
WHERE "{self.name}" IS NOT NULL
|
||||
AND jsonb_typeof("{self.name}") = 'object'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
ck.key_path_array || jsonb_object_keys(ck.value),
|
||||
ck.value -> jsonb_object_keys(ck.value) AS value
|
||||
FROM "{self.name}_keys" ck
|
||||
WHERE jsonb_typeof(ck.value) = 'object'
|
||||
),
|
||||
|
||||
unique_paths AS (
|
||||
SELECT DISTINCT key_path_array
|
||||
FROM "{self.name}_keys"
|
||||
)
|
||||
|
||||
SELECT key_path_array FROM unique_paths;
|
||||
""" # nosec
|
||||
)
|
||||
return (x[0] for x in cursor.fetchall())
|
||||
|
||||
def get_nested_options(self) -> OrderedDict:
|
||||
"""Get keys of all nested objects to show autocomplete"""
|
||||
if not self.suggest_nested:
|
||||
return OrderedDict()
|
||||
base_model_name = f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}"
|
||||
|
||||
def recursive_function(parts: list[str], parent_parts: list[str] | None = None):
|
||||
if not parent_parts:
|
||||
parent_parts = []
|
||||
path = parts.pop(0)
|
||||
parent_parts.append(path)
|
||||
relation_key = "_".join(parent_parts)
|
||||
if len(parts) > 1:
|
||||
out_dict = {
|
||||
relation_key: {
|
||||
parts[0]: {
|
||||
"type": "relation",
|
||||
"relation": f"{relation_key}_{parts[0]}",
|
||||
}
|
||||
}
|
||||
}
|
||||
child_paths = recursive_function(parts.copy(), parent_parts.copy())
|
||||
child_paths.update(out_dict)
|
||||
return child_paths
|
||||
else:
|
||||
return {relation_key: {parts[0]: {}}}
|
||||
|
||||
relation_structure = defaultdict(dict)
|
||||
|
||||
for relations in self.json_field_keys():
|
||||
result = recursive_function([base_model_name] + relations)
|
||||
for relation_key, value in result.items():
|
||||
for sub_relation_key, sub_value in value.items():
|
||||
if not relation_structure[relation_key].get(sub_relation_key, None):
|
||||
relation_structure[relation_key][sub_relation_key] = sub_value
|
||||
else:
|
||||
relation_structure[relation_key][sub_relation_key].update(sub_value)
|
||||
|
||||
final_dict = defaultdict(dict)
|
||||
|
||||
for key, value in relation_structure.items():
|
||||
for sub_key, sub_value in value.items():
|
||||
if not sub_value:
|
||||
final_dict[key][sub_key] = {
|
||||
"type": "str",
|
||||
"nullable": True,
|
||||
}
|
||||
else:
|
||||
final_dict[key][sub_key] = sub_value
|
||||
return OrderedDict(final_dict)
|
||||
|
||||
def relation(self) -> str:
|
||||
return f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}"
|
||||
|
||||
|
||||
class ChoiceSearchField(StrField):
|
||||
def __init__(self, model=None, name=None, nullable=None):
|
||||
super().__init__(model, name, nullable, suggest_options=True)
|
||||
|
||||
def get_options(self, search):
|
||||
result = []
|
||||
choices = self._field_choices()
|
||||
if choices:
|
||||
search = search.lower()
|
||||
for c in choices:
|
||||
choice = text_type(c[0])
|
||||
if search in choice.lower():
|
||||
result.append(choice)
|
||||
return result
|
||||
@@ -1,18 +1,15 @@
|
||||
"""DjangoQL search"""
|
||||
"""QL search"""
|
||||
|
||||
from akql.exceptions import AKQLError
|
||||
from akql.queryset import apply_search
|
||||
from akql.schema import AKQLSchema
|
||||
from django.apps import apps
|
||||
from django.db.models import QuerySet
|
||||
from djangoql.ast import Name
|
||||
from djangoql.exceptions import DjangoQLError
|
||||
from djangoql.queryset import apply_search
|
||||
from djangoql.schema import DjangoQLSchema
|
||||
from drf_spectacular.plumbing import ResolvedComponent, build_object_type
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.request import Request
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.enterprise.search.fields import JSONSearchField
|
||||
|
||||
LOGGER = get_logger()
|
||||
AUTOCOMPLETE_SCHEMA = ResolvedComponent(
|
||||
name="Autocomplete",
|
||||
@@ -22,27 +19,8 @@ AUTOCOMPLETE_SCHEMA = ResolvedComponent(
|
||||
)
|
||||
|
||||
|
||||
class BaseSchema(DjangoQLSchema):
|
||||
"""Base Schema which deals with JSON Fields"""
|
||||
|
||||
def resolve_name(self, name: Name):
|
||||
model = self.model_label(self.current_model)
|
||||
root_field = name.parts[0]
|
||||
field = self.models[model].get(root_field)
|
||||
# If the query goes into a JSON field, return the root
|
||||
# field as the JSON field will do the rest
|
||||
if isinstance(field, JSONSearchField):
|
||||
# This is a workaround; build_filter will remove the right-most
|
||||
# entry in the path as that is intended to be the same as the field
|
||||
# however for JSON that is not the case
|
||||
if name.parts[-1] != root_field:
|
||||
name.parts.append(root_field)
|
||||
return field
|
||||
return super().resolve_name(name)
|
||||
|
||||
|
||||
class QLSearch(SearchFilter):
|
||||
"""rest_framework search filter which uses DjangoQL"""
|
||||
"""rest_framework search filter which uses AKQL"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -59,24 +37,30 @@ class QLSearch(SearchFilter):
|
||||
params = params.replace("\x00", "") # strip null characters
|
||||
return params
|
||||
|
||||
def get_schema(self, request: Request, view) -> BaseSchema:
|
||||
def get_schema(self, request: Request, view) -> AKQLSchema:
|
||||
ql_fields = []
|
||||
if hasattr(view, "get_ql_fields"):
|
||||
ql_fields = view.get_ql_fields()
|
||||
|
||||
class InlineSchema(BaseSchema):
|
||||
class InlineSchema(AKQLSchema):
|
||||
def get_fields(self, model):
|
||||
return ql_fields or []
|
||||
|
||||
return InlineSchema
|
||||
|
||||
def get_search_context(self, request: Request):
|
||||
return {
|
||||
"$ak_user": request.user.pk,
|
||||
}
|
||||
|
||||
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
|
||||
search_query = self.get_search_terms(request)
|
||||
schema = self.get_schema(request, view)
|
||||
if len(search_query) == 0 or not self.enabled:
|
||||
return self._fallback.filter_queryset(request, queryset, view)
|
||||
context = self.get_search_context(request)
|
||||
try:
|
||||
return apply_search(queryset, search_query, schema=schema)
|
||||
except DjangoQLError as exc:
|
||||
return apply_search(queryset, search_query, context=context, schema=schema)
|
||||
except AKQLError as exc:
|
||||
LOGGER.debug("Failed to parse search expression", exc=exc)
|
||||
return self._fallback.filter_queryset(request, queryset, view)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from djangoql.serializers import DjangoQLSchemaSerializer
|
||||
from akql.schema import JSONSearchField
|
||||
from akql.serializers import AKQLSchemaSerializer
|
||||
from drf_spectacular.generators import SchemaGenerator
|
||||
|
||||
from authentik.enterprise.search.fields import JSONSearchField
|
||||
from authentik.enterprise.search.ql import AUTOCOMPLETE_SCHEMA
|
||||
|
||||
|
||||
class AKQLSchemaSerializer(DjangoQLSchemaSerializer):
|
||||
class AKQLSchemaSerializer(AKQLSchemaSerializer):
|
||||
def serialize(self, schema):
|
||||
serialization = super().serialize(schema)
|
||||
for _, fields in schema.models.items():
|
||||
@@ -15,12 +15,6 @@ class AKQLSchemaSerializer(DjangoQLSchemaSerializer):
|
||||
serialization["models"].update(field.get_nested_options())
|
||||
return serialization
|
||||
|
||||
def serialize_field(self, field):
|
||||
result = super().serialize_field(field)
|
||||
if isinstance(field, JSONSearchField):
|
||||
result["relation"] = field.relation()
|
||||
return result
|
||||
|
||||
|
||||
def postprocess_schema_search_autocomplete(result, generator: SchemaGenerator, **kwargs):
|
||||
generator.registry.register_on_missing(AUTOCOMPLETE_SCHEMA)
|
||||
|
||||
@@ -136,9 +136,7 @@ class EventViewSet(
|
||||
filterset_class = EventsFilter
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import DateTimeField, StrField
|
||||
|
||||
from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField
|
||||
from akql.schema import ChoiceSearchField, DateTimeField, JSONSearchField, StrField
|
||||
|
||||
return [
|
||||
ChoiceSearchField(Event, "action"),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2025.12.0-rc1 Blueprint schema",
|
||||
"title": "authentik 2026.2.0-rc1 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@@ -14510,7 +14510,8 @@
|
||||
"description": "Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password."
|
||||
},
|
||||
"webauthn_stage": {
|
||||
"type": "integer",
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Webauthn stage",
|
||||
"description": "When set, and conditional WebAuthn is available, allow the user to use their passkey as a first factor."
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.0-rc1}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.0-rc1}
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
volumes:
|
||||
|
||||
2
go.mod
2
go.mod
@@ -32,7 +32,7 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2025120.26
|
||||
goauthentik.io/api/v3 v3.2026020.1
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.19.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -214,8 +214,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
goauthentik.io/api/v3 v3.2025120.26 h1:2lTMtjCWtdOeQe7kwjpGUx39qUEpcxcxTirIqMvn0Os=
|
||||
goauthentik.io/api/v3 v3.2025120.26/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
goauthentik.io/api/v3 v3.2026020.1 h1:R7WdvVmfm066d3Zu7R+WfjDGdFqC/X2gONHIGPfcLzk=
|
||||
goauthentik.io/api/v3 v3.2026020.1/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025.12.0-rc1
|
||||
2026.2.0-rc1
|
||||
@@ -28,7 +28,7 @@ func (ps *ProxyServer) Refresh() error {
|
||||
return err
|
||||
}
|
||||
ps.log.WithField("count", len(providers)).Debug("Fetched providers")
|
||||
if len(providers) == 0 {
|
||||
if len(providers) == 0 && !ps.akAPI.IsEmbedded() {
|
||||
ps.log.Warning("No providers assigned to this outpost, check outpost configuration in authentik")
|
||||
}
|
||||
for i, p := range providers {
|
||||
|
||||
@@ -31,7 +31,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
go build -o /go/ldap ./cmd/ldap
|
||||
|
||||
# Stage 2: Run
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:07f41ce3f15b2bb5eb5bcd4e6efc0cb42bb7e5609e7244f636da1a91166817ca
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:2f19fc114923ec0842329bf638cb155e597c4be9c8119a3db038ffc3fede9228
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
|
||||
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1033.0",
|
||||
"aws-cdk": "^2.1034.0",
|
||||
"cross-env": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -25,9 +25,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1033.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1033.0.tgz",
|
||||
"integrity": "sha512-Pit2k7cVAwxoYI7RMVsOyltuy7/HGENLupJ4KAm/d8mGzOfX+SLOo9YQsx5CKY9J6ErCZ1ViLerklTfjytvQww==",
|
||||
"version": "2.1034.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1034.0.tgz",
|
||||
"integrity": "sha512-YsIeXmMP/9eGml/eoPs64kHzNR0IVezzwuH0XrLOtUCjYNb80cmmjoCNsMn96u9rJOte1Yg3jitrHi1wTqXAqw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"aws-cfn": "cross-env CI=false cdk synth --version-reporting=false > template.yaml"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1033.0",
|
||||
"aws-cdk": "^2.1034.0",
|
||||
"cross-env": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -18,7 +18,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.12.0-rc1
|
||||
Default: 2026.2.0-rc1
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2026.2.0-rc1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2026.2.0-rc1",
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@goauthentik/eslint-config": "./packages/eslint-config",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2026.2.0-rc1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
21
packages/akql/LICENSE
Normal file
21
packages/akql/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 ivelum
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
3
packages/akql/README.md
Normal file
3
packages/akql/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
This is a fork of djangoql.
|
||||
|
||||
https://github.com/ivelum/djangoql
|
||||
1
packages/akql/akql/__init__.py
Normal file
1
packages/akql/akql/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.18.1"
|
||||
91
packages/akql/akql/ast.py
Normal file
91
packages/akql/akql/ast.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from akql.parser import AKQLParser
|
||||
|
||||
|
||||
class Node:
|
||||
def __str__(self):
|
||||
children = []
|
||||
for k, v in self.__dict__.items():
|
||||
vv = v
|
||||
if isinstance(v, list | tuple):
|
||||
vv = "[{}]".format(", ".join([str(v) for v in v if v]))
|
||||
children.append(f"{k}={vv}")
|
||||
return "<{}{}{}>".format(
|
||||
self.__class__.__name__,
|
||||
": " if children else "",
|
||||
", ".join(children),
|
||||
)
|
||||
|
||||
__repr__ = __str__
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return False
|
||||
for k, v in self.__dict__.items():
|
||||
if getattr(other, k) != v:
|
||||
return False
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
|
||||
class Expression(Node):
|
||||
def __init__(self, left, operator, right):
|
||||
self.left = left
|
||||
self.operator = operator
|
||||
self.right = right
|
||||
|
||||
|
||||
class Name(Node):
|
||||
def __init__(self, parts):
|
||||
if isinstance(parts, list):
|
||||
self.parts = parts
|
||||
elif isinstance(parts, tuple):
|
||||
self.parts = list(parts)
|
||||
else:
|
||||
self.parts = [parts]
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return ".".join(self.parts)
|
||||
|
||||
|
||||
class Const(Node):
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
class List(Node):
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return [i.value for i in self.items]
|
||||
|
||||
|
||||
class Operator(Node):
|
||||
def __init__(self, operator):
|
||||
self.operator = operator
|
||||
|
||||
|
||||
class Logical(Operator):
|
||||
pass
|
||||
|
||||
|
||||
class Comparison(Operator):
|
||||
pass
|
||||
|
||||
|
||||
class Variable(Node):
|
||||
|
||||
def __init__(self, name: str, parser: "AKQLParser"):
|
||||
self.name = name
|
||||
self.parser = parser
|
||||
|
||||
@property
|
||||
def value(self) -> Any:
|
||||
return self.parser.context.get(self.name)
|
||||
32
packages/akql/akql/exceptions.py
Normal file
32
packages/akql/akql/exceptions.py
Normal file
@@ -0,0 +1,32 @@
|
||||
class AKQLError(Exception):
|
||||
def __init__(self, message=None, value=None, line=None, column=None):
|
||||
self.value = value
|
||||
self.line = line
|
||||
self.column = column
|
||||
super().__init__(message)
|
||||
|
||||
def __str__(self):
|
||||
message = super().__str__()
|
||||
if self.line:
|
||||
position_info = f"Line {self.line}"
|
||||
if self.column:
|
||||
position_info += f", col {self.column}"
|
||||
return f"{position_info}: {message}"
|
||||
else:
|
||||
return message
|
||||
|
||||
|
||||
class AKQLSyntaxError(AKQLError):
|
||||
pass
|
||||
|
||||
|
||||
class AKQLLexerError(AKQLSyntaxError):
|
||||
pass
|
||||
|
||||
|
||||
class AKQLParserError(AKQLSyntaxError):
|
||||
pass
|
||||
|
||||
|
||||
class AKQLSchemaError(AKQLError):
|
||||
pass
|
||||
181
packages/akql/akql/lexer.py
Normal file
181
packages/akql/akql/lexer.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from ply import lex
|
||||
from ply.lex import TOKEN, Lexer, LexToken
|
||||
|
||||
from akql.exceptions import AKQLLexerError
|
||||
|
||||
|
||||
class AKQLLexer:
|
||||
_lexer: Lexer
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._lexer = lex.lex(module=self, **kwargs)
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.text = ""
|
||||
self._lexer.lineno = 1
|
||||
return self
|
||||
|
||||
def input(self, s):
|
||||
self.reset()
|
||||
self.text = s
|
||||
self._lexer.input(s)
|
||||
return self
|
||||
|
||||
def token(self):
|
||||
return self._lexer.token()
|
||||
|
||||
# Iterator interface
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def next(self):
|
||||
t = self.token()
|
||||
if t is None:
|
||||
raise StopIteration
|
||||
return t
|
||||
|
||||
__next__ = next
|
||||
|
||||
def find_column(self, t: LexToken):
|
||||
"""
|
||||
Returns token position in current text, starting from 1
|
||||
"""
|
||||
cr = max(self.text.rfind(lt, 0, t.lexpos) for lt in self.line_terminators)
|
||||
if cr == -1:
|
||||
return t.lexpos + 1
|
||||
return t.lexpos - cr
|
||||
|
||||
whitespace = " \t\v\f\u00a0"
|
||||
line_terminators = "\n\r\u2028\u2029"
|
||||
|
||||
re_line_terminators = r"\n\r\u2028\u2029"
|
||||
|
||||
re_escaped_char = r"\\[\"\\/bfnrt]"
|
||||
re_escaped_unicode = r"\\u[0-9A-Fa-f]{4}"
|
||||
re_string_char = r"[^\"\\" + re_line_terminators + "]"
|
||||
|
||||
re_int_value = r"(-?0|-?[1-9][0-9]*)"
|
||||
re_fraction_part = r"\.[0-9]+"
|
||||
re_exponent_part = r"[eE][\+-]?[0-9]+"
|
||||
|
||||
tokens = [
|
||||
"COMMA",
|
||||
"OR",
|
||||
"AND",
|
||||
"NOT",
|
||||
"IN",
|
||||
"TRUE",
|
||||
"FALSE",
|
||||
"NONE",
|
||||
"NAME",
|
||||
"STRING_VALUE",
|
||||
"FLOAT_VALUE",
|
||||
"INT_VALUE",
|
||||
"PAREN_L",
|
||||
"PAREN_R",
|
||||
"EQUALS",
|
||||
"NOT_EQUALS",
|
||||
"GREATER",
|
||||
"GREATER_EQUAL",
|
||||
"LESS",
|
||||
"LESS_EQUAL",
|
||||
"CONTAINS",
|
||||
"NOT_CONTAINS",
|
||||
"STARTSWITH",
|
||||
"ENDSWITH",
|
||||
"VARIABLE",
|
||||
]
|
||||
|
||||
t_COMMA = ","
|
||||
t_PAREN_L = r"\("
|
||||
t_PAREN_R = r"\)"
|
||||
t_EQUALS = "="
|
||||
t_NOT_EQUALS = "!="
|
||||
t_GREATER = ">"
|
||||
t_GREATER_EQUAL = ">="
|
||||
t_LESS = "<"
|
||||
t_LESS_EQUAL = "<="
|
||||
t_CONTAINS = "~"
|
||||
t_NOT_CONTAINS = "!~"
|
||||
|
||||
t_NAME = r"[_A-Za-z][_0-9A-Za-z]*(\.[_A-Za-z][_0-9A-Za-z]*)*"
|
||||
|
||||
t_ignore = whitespace
|
||||
|
||||
@TOKEN(r"\$([_A-Za-z\.]+)")
|
||||
def t_VARIABLE(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN(r"\"(" + re_escaped_char + "|" + re_escaped_unicode + "|" + re_string_char + r")*\"")
|
||||
def t_STRING_VALUE(self, t: LexToken):
|
||||
t.value = t.value[1:-1] # cut leading and trailing quotes ""
|
||||
return t
|
||||
|
||||
@TOKEN(
|
||||
re_int_value
|
||||
+ re_fraction_part
|
||||
+ re_exponent_part
|
||||
+ "|"
|
||||
+ re_int_value
|
||||
+ re_fraction_part
|
||||
+ "|"
|
||||
+ re_int_value
|
||||
+ re_exponent_part
|
||||
)
|
||||
def t_FLOAT_VALUE(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN(re_int_value)
|
||||
def t_INT_VALUE(self, t: LexToken):
|
||||
return t
|
||||
|
||||
not_followed_by_name = "(?![_0-9A-Za-z])"
|
||||
|
||||
@TOKEN("or" + not_followed_by_name)
|
||||
def t_OR(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN("and" + not_followed_by_name)
|
||||
def t_AND(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN("not" + not_followed_by_name)
|
||||
def t_NOT(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN("in" + not_followed_by_name)
|
||||
def t_IN(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN("startswith" + not_followed_by_name)
|
||||
def t_STARTSWITH(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN("endswith" + not_followed_by_name)
|
||||
def t_ENDSWITH(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN("True" + not_followed_by_name)
|
||||
def t_TRUE(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN("False" + not_followed_by_name)
|
||||
def t_FALSE(self, t: LexToken):
|
||||
return t
|
||||
|
||||
@TOKEN("None" + not_followed_by_name)
|
||||
def t_NONE(self, t: LexToken):
|
||||
return t
|
||||
|
||||
def t_error(self, t: LexToken):
|
||||
raise AKQLLexerError(
|
||||
message=f"Illegal character {repr(t.value[0])}",
|
||||
value=t.value,
|
||||
line=t.lineno,
|
||||
column=self.find_column(t),
|
||||
)
|
||||
|
||||
@TOKEN("[" + re_line_terminators + "]+")
|
||||
def t_newline(self, t: LexToken):
|
||||
t.lexer.lineno += len(t.value)
|
||||
239
packages/akql/akql/parser.py
Normal file
239
packages/akql/akql/parser.py
Normal file
@@ -0,0 +1,239 @@
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from ply import yacc
|
||||
from ply.yacc import LRParser, YaccProduction
|
||||
|
||||
from akql.ast import Comparison, Const, Expression, List, Logical, Name, Variable
|
||||
from akql.exceptions import AKQLParserError
|
||||
from akql.lexer import AKQLLexer
|
||||
|
||||
unescape_pattern = re.compile(
|
||||
"(" + AKQLLexer.re_escaped_char + "|" + AKQLLexer.re_escaped_unicode + ")",
|
||||
)
|
||||
|
||||
|
||||
def unescape_repl(m: re.Match[str]) -> str:
|
||||
contents = m.group(1)
|
||||
if len(contents) == 2: # noqa
|
||||
return contents[1]
|
||||
else:
|
||||
return contents.encode("utf8").decode("unicode_escape")
|
||||
|
||||
|
||||
def unescape(value):
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf8")
|
||||
return re.sub(unescape_pattern, unescape_repl, value)
|
||||
|
||||
|
||||
class AKQLParser:
|
||||
yacc: LRParser
|
||||
context: dict[str, Any]
|
||||
|
||||
def __init__(self, debug=False, context: dict[str, Any] | None = None, **kwargs):
|
||||
self.default_lexer = AKQLLexer()
|
||||
self.tokens = self.default_lexer.tokens
|
||||
kwargs["debug"] = debug
|
||||
if "write_tables" not in kwargs:
|
||||
kwargs["write_tables"] = False
|
||||
self.context = context or {}
|
||||
self.yacc = yacc.yacc(module=self, **kwargs)
|
||||
|
||||
def parse(
|
||||
self, input=None, lexer: AKQLLexer | None = None, **kwargs
|
||||
) -> Expression: # noqa: A002
|
||||
lexer = lexer or self.default_lexer
|
||||
return self.yacc.parse(input=input, lexer=lexer, **kwargs)
|
||||
|
||||
start = "expression"
|
||||
|
||||
def p_expression_parens(self, p: YaccProduction):
|
||||
"""
|
||||
expression : PAREN_L expression PAREN_R
|
||||
"""
|
||||
p[0] = p[2]
|
||||
|
||||
def p_expression_logical(self, p: YaccProduction):
|
||||
"""
|
||||
expression : expression logical expression
|
||||
"""
|
||||
p[0] = Expression(left=p[1], operator=p[2], right=p[3])
|
||||
|
||||
def p_expression_comparison(self, p: YaccProduction):
|
||||
"""
|
||||
expression : name comparison_number number
|
||||
| name comparison_string string
|
||||
| name comparison_equality boolean_value
|
||||
| name comparison_equality none
|
||||
| name comparison_in_list const_list_value
|
||||
| name comparison_number variable
|
||||
| name comparison_string variable
|
||||
| name comparison_equality variable
|
||||
| name comparison_in_list variable
|
||||
"""
|
||||
p[0] = Expression(left=p[1], operator=p[2], right=p[3])
|
||||
|
||||
def p_name(self, p: YaccProduction):
|
||||
"""
|
||||
name : NAME
|
||||
"""
|
||||
p[0] = Name(parts=p[1].split("."))
|
||||
|
||||
def p_logical(self, p: YaccProduction):
|
||||
"""
|
||||
logical : AND
|
||||
| OR
|
||||
"""
|
||||
p[0] = Logical(operator=p[1])
|
||||
|
||||
def p_comparison_number(self, p: YaccProduction):
|
||||
"""
|
||||
comparison_number : comparison_equality
|
||||
| comparison_greater_less
|
||||
"""
|
||||
p[0] = p[1]
|
||||
|
||||
def p_comparison_string(self, p: YaccProduction):
|
||||
"""
|
||||
comparison_string : comparison_equality
|
||||
| comparison_greater_less
|
||||
| comparison_string_specific
|
||||
"""
|
||||
p[0] = p[1]
|
||||
|
||||
def p_comparison_equality(self, p: YaccProduction):
|
||||
"""
|
||||
comparison_equality : EQUALS
|
||||
| NOT_EQUALS
|
||||
"""
|
||||
p[0] = Comparison(operator=p[1])
|
||||
|
||||
def p_comparison_greater_less(self, p: YaccProduction):
|
||||
"""
|
||||
comparison_greater_less : GREATER
|
||||
| GREATER_EQUAL
|
||||
| LESS
|
||||
| LESS_EQUAL
|
||||
"""
|
||||
p[0] = Comparison(operator=p[1])
|
||||
|
||||
def p_comparison_string_specific(self, p: YaccProduction):
|
||||
"""
|
||||
comparison_string_specific : CONTAINS
|
||||
| NOT_CONTAINS
|
||||
| STARTSWITH
|
||||
| NOT STARTSWITH
|
||||
| ENDSWITH
|
||||
| NOT ENDSWITH
|
||||
"""
|
||||
p[0] = Comparison(operator=" ".join(p[1:]))
|
||||
|
||||
def p_comparison_in_list(self, p: YaccProduction):
|
||||
"""
|
||||
comparison_in_list : IN
|
||||
| NOT IN
|
||||
"""
|
||||
p[0] = Comparison(operator=" ".join(p[1:]))
|
||||
|
||||
def p_const_value(self, p: YaccProduction):
|
||||
"""
|
||||
const_value : number
|
||||
| string
|
||||
| none
|
||||
| boolean_value
|
||||
"""
|
||||
p[0] = p[1]
|
||||
|
||||
def p_variable(self, p: YaccProduction):
|
||||
"""
|
||||
variable : VARIABLE
|
||||
"""
|
||||
p[0] = Variable(name=unescape(p[1]), parser=self)
|
||||
|
||||
def p_number_int(self, p: YaccProduction):
|
||||
"""
|
||||
number : INT_VALUE
|
||||
"""
|
||||
p[0] = Const(value=int(p[1]))
|
||||
|
||||
def p_number_float(self, p: YaccProduction):
|
||||
"""
|
||||
number : FLOAT_VALUE
|
||||
"""
|
||||
p[0] = Const(value=Decimal(p[1]))
|
||||
|
||||
def p_string(self, p: YaccProduction):
|
||||
"""
|
||||
string : STRING_VALUE
|
||||
"""
|
||||
p[0] = Const(value=unescape(p[1]))
|
||||
|
||||
def p_none(self, p: YaccProduction):
|
||||
"""
|
||||
none : NONE
|
||||
"""
|
||||
p[0] = Const(value=None)
|
||||
|
||||
def p_boolean_value(self, p: YaccProduction):
|
||||
"""
|
||||
boolean_value : true
|
||||
| false
|
||||
"""
|
||||
p[0] = p[1]
|
||||
|
||||
def p_true(self, p: YaccProduction):
|
||||
"""
|
||||
true : TRUE
|
||||
"""
|
||||
p[0] = Const(value=True)
|
||||
|
||||
def p_false(self, p: YaccProduction):
|
||||
"""
|
||||
false : FALSE
|
||||
"""
|
||||
p[0] = Const(value=False)
|
||||
|
||||
def p_const_list_value(self, p: YaccProduction):
|
||||
"""
|
||||
const_list_value : PAREN_L const_value_list PAREN_R
|
||||
"""
|
||||
p[0] = List(items=p[2])
|
||||
|
||||
def p_const_value_list(self, p: YaccProduction):
|
||||
"""
|
||||
const_value_list : const_value_list COMMA const_value
|
||||
"""
|
||||
p[0] = p[1] + [p[3]]
|
||||
|
||||
def p_const_value_list_single(self, p: YaccProduction):
|
||||
"""
|
||||
const_value_list : const_value
|
||||
"""
|
||||
p[0] = [p[1]]
|
||||
|
||||
def p_error(self, token):
|
||||
if token is None:
|
||||
self.raise_syntax_error("Unexpected end of input")
|
||||
else:
|
||||
fragment = str(token.value)
|
||||
self.raise_syntax_error(
|
||||
f"Syntax error at {repr(fragment)}",
|
||||
token=token,
|
||||
)
|
||||
|
||||
def raise_syntax_error(self, message, token=None):
|
||||
if token is None:
|
||||
raise AKQLParserError(message)
|
||||
lexer = token.lexer
|
||||
if callable(getattr(lexer, "find_column", None)):
|
||||
column = lexer.find_column(token)
|
||||
else:
|
||||
column = None
|
||||
raise AKQLParserError(
|
||||
message=message,
|
||||
value=token.value,
|
||||
line=token.lineno,
|
||||
column=column,
|
||||
)
|
||||
1113
packages/akql/akql/parsetab.py
Normal file
1113
packages/akql/akql/parsetab.py
Normal file
File diff suppressed because it is too large
Load Diff
47
packages/akql/akql/queryset.py
Normal file
47
packages/akql/akql/queryset.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from akql.ast import Logical
|
||||
from akql.parser import AKQLParser
|
||||
from akql.schema import AKQLField, AKQLSchema
|
||||
|
||||
|
||||
def build_filter(expr: str, schema_instance: AKQLSchema):
|
||||
if isinstance(expr.operator, Logical):
|
||||
left = build_filter(expr.left, schema_instance)
|
||||
right = build_filter(expr.right, schema_instance)
|
||||
if expr.operator.operator == "or":
|
||||
return left | right
|
||||
else:
|
||||
return left & right
|
||||
|
||||
field = schema_instance.resolve_name(expr.left)
|
||||
if not field:
|
||||
# That must be a reference to a model without specifying a field.
|
||||
# Let's construct an abstract lookup field for it
|
||||
field = AKQLField(
|
||||
name=expr.left.parts[-1],
|
||||
nullable=True,
|
||||
)
|
||||
return field.get_lookup(
|
||||
path=expr.left.parts[:-1],
|
||||
operator=expr.operator.operator,
|
||||
value=expr.right.value,
|
||||
)
|
||||
|
||||
|
||||
def apply_search(
|
||||
queryset: QuerySet,
|
||||
search: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
schema: type[AKQLSchema] | None = None,
|
||||
) -> QuerySet:
|
||||
"""
|
||||
Applies search written in DjangoQL mini-language to given queryset
|
||||
"""
|
||||
ast = AKQLParser(context=context).parse(search)
|
||||
schema = schema or AKQLSchema
|
||||
schema_instance = schema(queryset.model)
|
||||
schema_instance.validate(ast)
|
||||
return queryset.filter(build_filter(ast, schema_instance))
|
||||
618
packages/akql/akql/schema.py
Normal file
618
packages/akql/akql/schema.py
Normal file
@@ -0,0 +1,618 @@
|
||||
import inspect
|
||||
import warnings
|
||||
from collections import OrderedDict, defaultdict, deque
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db import connection, models
|
||||
from django.db.models import ManyToManyRel, ManyToOneRel, Model, Q
|
||||
from django.db.models.fields.related import ForeignObjectRel
|
||||
from django.utils.timezone import get_current_timezone
|
||||
|
||||
from akql.ast import Comparison, Const, List, Logical, Name, Node, Variable
|
||||
from akql.exceptions import AKQLSchemaError
|
||||
|
||||
|
||||
class AKQLField:
|
||||
"""
|
||||
Abstract searchable field
|
||||
"""
|
||||
|
||||
model = None
|
||||
name = None
|
||||
nullable = False
|
||||
suggest_options = False
|
||||
type = "unknown"
|
||||
value_types = []
|
||||
value_types_description = ""
|
||||
|
||||
def __init__(self, model=None, name=None, nullable=None, suggest_options=None):
|
||||
if model is not None:
|
||||
self.model = model
|
||||
if name is not None:
|
||||
self.name = name
|
||||
if nullable is not None:
|
||||
self.nullable = nullable
|
||||
if suggest_options is not None:
|
||||
self.suggest_options = suggest_options
|
||||
|
||||
def _field_choices(self):
|
||||
if self.model:
|
||||
try:
|
||||
return self.model._meta.get_field(self.name).choices
|
||||
except (AttributeError, FieldDoesNotExist):
|
||||
pass
|
||||
return []
|
||||
|
||||
@property
|
||||
def async_options(self):
|
||||
return not self._field_choices()
|
||||
|
||||
def get_options(self, search):
|
||||
"""
|
||||
Override this method to provide custom suggestion options
|
||||
"""
|
||||
result = []
|
||||
choices = self._field_choices()
|
||||
if choices:
|
||||
search = search.lower()
|
||||
for c in choices:
|
||||
choice = str(c[1])
|
||||
if search in choice.lower():
|
||||
result.append(choice)
|
||||
return result
|
||||
|
||||
def get_lookup_name(self):
|
||||
"""
|
||||
Override this method to provide custom lookup name
|
||||
"""
|
||||
return self.name
|
||||
|
||||
def get_lookup_value(self, value):
|
||||
"""
|
||||
Override this method to convert displayed values to lookup values
|
||||
"""
|
||||
choices = self._field_choices()
|
||||
if choices:
|
||||
if isinstance(value, list):
|
||||
return [c[0] for c in choices if c[0] in value or c[1] in value]
|
||||
else:
|
||||
for c in choices:
|
||||
if value in c:
|
||||
return c[0]
|
||||
return value
|
||||
|
||||
def get_operator(self, operator):
|
||||
"""
|
||||
Get a comparison suffix to be used in Django ORM & inversion flag for it
|
||||
|
||||
:param operator: string, DjangoQL comparison operator
|
||||
:return: (suffix, invert) - a tuple with 2 values:
|
||||
suffix - suffix to be used in ORM query, for example '__gt' for '>'
|
||||
invert - boolean, True if this comparison needs to be inverted
|
||||
"""
|
||||
op = {
|
||||
"=": "",
|
||||
">": "__gt",
|
||||
">=": "__gte",
|
||||
"<": "__lt",
|
||||
"<=": "__lte",
|
||||
"~": "__icontains",
|
||||
"in": "__in",
|
||||
"startswith": "__istartswith",
|
||||
"endswith": "__iendswith",
|
||||
}.get(operator)
|
||||
if op is not None:
|
||||
return op, False
|
||||
op = {
|
||||
"!=": "",
|
||||
"!~": "__icontains",
|
||||
"not in": "__in",
|
||||
"not startswith": "__istartswith",
|
||||
"not endswith": "__iendswith",
|
||||
}[operator]
|
||||
return op, True
|
||||
|
||||
def get_lookup(self, path, operator, value):
|
||||
"""
|
||||
Performs a lookup for this field with given path, operator and value.
|
||||
|
||||
Override this if you'd like to implement a fully custom lookup. It
|
||||
should support all comparison operators compatible with the field type.
|
||||
|
||||
:param path: a list of names preceding current lookup. For example,
|
||||
if expression looks like 'author.groups.name = "Foo"' path would
|
||||
be ['author', 'groups']. 'name' is not included, because it's the
|
||||
current field instance itself.
|
||||
:param operator: a string with comparison operator. It could be one of
|
||||
the following: '=', '!=', '>', '>=', '<', '<=', '~', '!~', 'in',
|
||||
'not in'. Depending on the field type, some operators may be
|
||||
excluded. '~' and '!~' can be applied to StrField only and aren't
|
||||
allowed for any other fields. BoolField can't be used with less or
|
||||
greater operators, '>', '>=', '<' and '<=' are excluded for it.
|
||||
:param value: value passed for comparison
|
||||
:return: Q-object
|
||||
"""
|
||||
search = "__".join(path + [self.get_lookup_name()])
|
||||
op, invert = self.get_operator(operator)
|
||||
q = models.Q(**{f"{search}{op}": self.get_lookup_value(value)})
|
||||
return ~q if invert else q
|
||||
|
||||
def validate(self, value):
|
||||
if not self.nullable and value is None:
|
||||
raise AKQLSchemaError(
|
||||
f"Field {self.name} is not nullable, " "can't compare it to None",
|
||||
)
|
||||
if value is not None and type(value) not in self.value_types:
|
||||
if self.nullable:
|
||||
msg = (
|
||||
'Field "{field}" has "nullable {field_type}" type. '
|
||||
"It can be compared to {possible_values} or None, "
|
||||
"but not to {value}"
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
'Field "{field}" has "{field_type}" type. It can '
|
||||
"be compared to {possible_values}, "
|
||||
"but not to {value}"
|
||||
)
|
||||
raise AKQLSchemaError(
|
||||
msg.format(
|
||||
field=self.name,
|
||||
field_type=self.type,
|
||||
possible_values=self.value_types_description,
|
||||
value=repr(value),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class IntField(AKQLField):
|
||||
type = "int"
|
||||
value_types = [int]
|
||||
value_types_description = "integer numbers"
|
||||
|
||||
def validate(self, value):
|
||||
"""
|
||||
Support enum-like choices defined on an integer field
|
||||
"""
|
||||
return super().validate(self.get_lookup_value(value))
|
||||
|
||||
|
||||
class FloatField(AKQLField):
|
||||
type = "float"
|
||||
value_types = [int, float, Decimal]
|
||||
value_types_description = "floating point numbers"
|
||||
|
||||
|
||||
class StrField(AKQLField):
|
||||
type = "str"
|
||||
value_types = [str]
|
||||
value_types_description = "strings"
|
||||
|
||||
def get_options(self, search):
|
||||
choice_options = super().get_options(search)
|
||||
if choice_options:
|
||||
return choice_options
|
||||
lookup = {}
|
||||
if search:
|
||||
lookup[f"{self.name}__icontains"] = search
|
||||
return (
|
||||
self.model.objects.filter(**lookup)
|
||||
.order_by(self.name)
|
||||
.values_list(self.name, flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class BoolField(AKQLField):
|
||||
type = "bool"
|
||||
value_types = [bool]
|
||||
value_types_description = "True or False"
|
||||
|
||||
|
||||
class DateField(AKQLField):
|
||||
type = "date"
|
||||
value_types = [str]
|
||||
value_types_description = 'dates in "YYYY-MM-DD" format'
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
try:
|
||||
self.get_lookup_value(value)
|
||||
except ValueError as exc:
|
||||
raise AKQLSchemaError(
|
||||
f'Field "{self.name}" can be compared to dates in '
|
||||
f'"YYYY-MM-DD" format, but not to {repr(value)}',
|
||||
) from exc
|
||||
|
||||
def get_lookup_value(self, value):
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
class DateTimeField(AKQLField):
|
||||
type = "datetime"
|
||||
value_types = [str]
|
||||
value_types_description = 'timestamps in "YYYY-MM-DD HH:MM" format'
|
||||
|
||||
def validate(self, value):
|
||||
super().validate(value)
|
||||
try:
|
||||
self.get_lookup_value(value)
|
||||
except ValueError as exc:
|
||||
raise AKQLSchemaError(
|
||||
f'Field "{self.name}" can be compared to timestamps in '
|
||||
f'"YYYY-MM-DD HH:MM" format, but not to {repr(value)}',
|
||||
) from exc
|
||||
|
||||
def get_lookup_value(self, value):
|
||||
if not value:
|
||||
return None
|
||||
for format in [
|
||||
"%Y-%m-%d",
|
||||
"%Y-%m-%d %H:%M",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
]:
|
||||
try:
|
||||
dt = datetime.strptime(value, format)
|
||||
if settings.USE_TZ:
|
||||
dt = dt.replace(tzinfo=get_current_timezone())
|
||||
return dt
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_lookup(self, path, operator, value):
|
||||
search = "__".join(path + [self.get_lookup_name()])
|
||||
op, invert = self.get_operator(operator)
|
||||
|
||||
# Add LIKE operator support for datetime fields. For LIKE comparisons
|
||||
# we don't want to convert source value to datetime instance, because
|
||||
# it would effectively kill the idea. What we want is expressions like
|
||||
# 'created ~ "2017-01-30'
|
||||
# to be translated to
|
||||
# 'created LIKE %2017-01-30%',
|
||||
# but it would work only if we pass a string as a parameter. If we pass
|
||||
# a datetime instance, it would add time part in a form of 00:00:00,
|
||||
# and resulting comparison would look like
|
||||
# 'created LIKE %2017-01-30 00:00:00%'
|
||||
# which is not what we want for this case.
|
||||
val = value if operator in ("~", "!~") else self.get_lookup_value(value)
|
||||
|
||||
q = models.Q(**{f"{search}{op}": val})
|
||||
return ~q if invert else q
|
||||
|
||||
|
||||
class RelationField(AKQLField):
|
||||
type = "relation"
|
||||
|
||||
def __init__(self, model, name, related_model, nullable=False, suggest_options=False):
|
||||
super().__init__(
|
||||
model=model,
|
||||
name=name,
|
||||
nullable=nullable,
|
||||
suggest_options=suggest_options,
|
||||
)
|
||||
self.related_model = related_model
|
||||
|
||||
@property
|
||||
def relation(self):
|
||||
return AKQLSchema.model_label(self.related_model)
|
||||
|
||||
|
||||
class JSONSearchField(StrField):
|
||||
"""JSON field for DjangoQL"""
|
||||
|
||||
model: Model
|
||||
|
||||
def __init__(self, model=None, name=None, nullable=None, suggest_nested=True):
|
||||
# Set this in the constructor to not clobber the type variable
|
||||
self.type = "relation"
|
||||
self.suggest_nested = suggest_nested
|
||||
super().__init__(model, name, nullable)
|
||||
|
||||
def get_lookup(self, path, operator, value):
|
||||
search = "__".join(path)
|
||||
op, invert = self.get_operator(operator)
|
||||
q = Q(**{f"{search}{op}": self.get_lookup_value(value)})
|
||||
return ~q if invert else q
|
||||
|
||||
def json_field_keys(self) -> Generator[tuple[str]]:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
WITH RECURSIVE "{self.name}_keys" AS (
|
||||
SELECT
|
||||
ARRAY[jsonb_object_keys("{self.name}")] AS key_path_array,
|
||||
"{self.name}" -> jsonb_object_keys("{self.name}") AS value
|
||||
FROM {self.model._meta.db_table}
|
||||
WHERE "{self.name}" IS NOT NULL
|
||||
AND jsonb_typeof("{self.name}") = 'object'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
ck.key_path_array || jsonb_object_keys(ck.value),
|
||||
ck.value -> jsonb_object_keys(ck.value) AS value
|
||||
FROM "{self.name}_keys" ck
|
||||
WHERE jsonb_typeof(ck.value) = 'object'
|
||||
),
|
||||
|
||||
unique_paths AS (
|
||||
SELECT DISTINCT key_path_array
|
||||
FROM "{self.name}_keys"
|
||||
)
|
||||
|
||||
SELECT key_path_array FROM unique_paths;
|
||||
""" # nosec
|
||||
)
|
||||
return (x[0] for x in cursor.fetchall())
|
||||
|
||||
def get_nested_options(self) -> OrderedDict:
|
||||
"""Get keys of all nested objects to show autocomplete"""
|
||||
if not self.suggest_nested:
|
||||
return OrderedDict()
|
||||
base_model_name = f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}"
|
||||
|
||||
def recursive_function(parts: list[str], parent_parts: list[str] | None = None):
|
||||
if not parent_parts:
|
||||
parent_parts = []
|
||||
path = parts.pop(0)
|
||||
parent_parts.append(path)
|
||||
relation_key = "_".join(parent_parts)
|
||||
if len(parts) > 1:
|
||||
out_dict = {
|
||||
relation_key: {
|
||||
parts[0]: {
|
||||
"type": "relation",
|
||||
"relation": f"{relation_key}_{parts[0]}",
|
||||
}
|
||||
}
|
||||
}
|
||||
child_paths = recursive_function(parts.copy(), parent_parts.copy())
|
||||
child_paths.update(out_dict)
|
||||
return child_paths
|
||||
else:
|
||||
return {relation_key: {parts[0]: {}}}
|
||||
|
||||
relation_structure = defaultdict(dict)
|
||||
|
||||
for relations in self.json_field_keys():
|
||||
result = recursive_function([base_model_name] + relations)
|
||||
for relation_key, value in result.items():
|
||||
for sub_relation_key, sub_value in value.items():
|
||||
if not relation_structure[relation_key].get(sub_relation_key, None):
|
||||
relation_structure[relation_key][sub_relation_key] = sub_value
|
||||
else:
|
||||
relation_structure[relation_key][sub_relation_key].update(sub_value)
|
||||
|
||||
final_dict = defaultdict(dict)
|
||||
|
||||
for key, value in relation_structure.items():
|
||||
for sub_key, sub_value in value.items():
|
||||
if not sub_value:
|
||||
final_dict[key][sub_key] = {
|
||||
"type": "str",
|
||||
"nullable": True,
|
||||
}
|
||||
else:
|
||||
final_dict[key][sub_key] = sub_value
|
||||
return OrderedDict(final_dict)
|
||||
|
||||
def relation(self) -> str:
|
||||
return f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}"
|
||||
|
||||
|
||||
class ChoiceSearchField(StrField):
|
||||
def __init__(self, model=None, name=None, nullable=None):
|
||||
super().__init__(model, name, nullable, suggest_options=True)
|
||||
|
||||
def get_options(self, search):
|
||||
result = []
|
||||
choices = self._field_choices()
|
||||
if choices:
|
||||
search = search.lower()
|
||||
for c in choices:
|
||||
choice = str(c[0])
|
||||
if search in choice.lower():
|
||||
result.append(choice)
|
||||
return result
|
||||
|
||||
|
||||
class AKQLSchema:
|
||||
include = () # models to include into introspection
|
||||
exclude = () # models to exclude from introspection
|
||||
suggest_options = None
|
||||
|
||||
def __init__(self, model):
|
||||
if not inspect.isclass(model) or not issubclass(model, models.Model):
|
||||
raise AKQLSchemaError(
|
||||
"Schema must be initialized with a subclass of Django model",
|
||||
)
|
||||
if self.include and self.exclude:
|
||||
raise AKQLSchemaError(
|
||||
"Either include or exclude can be specified, but not both",
|
||||
)
|
||||
if self.excluded(model):
|
||||
raise AKQLSchemaError(
|
||||
f"{model} can't be used with {self.__class__} because it's excluded from it",
|
||||
)
|
||||
self.current_model = model
|
||||
self._models = None
|
||||
if self.suggest_options is None:
|
||||
self.suggest_options = {}
|
||||
|
||||
def excluded(self, model):
|
||||
return model in self.exclude or (self.include and model not in self.include)
|
||||
|
||||
@property
|
||||
def models(self):
|
||||
if not self._models:
|
||||
self._models = self.introspect(
|
||||
model=self.current_model,
|
||||
exclude=tuple(self.model_label(m) for m in self.exclude),
|
||||
)
|
||||
return self._models
|
||||
|
||||
@classmethod
|
||||
def model_label(self, model):
|
||||
return str(model._meta)
|
||||
|
||||
def introspect(self, model, exclude=()):
|
||||
"""
|
||||
Start with given model and recursively walk through its relationships.
|
||||
|
||||
Returns a dict with all model labels and their fields found.
|
||||
"""
|
||||
result = {}
|
||||
open_set = deque([model])
|
||||
closed_set = set(exclude)
|
||||
|
||||
while open_set:
|
||||
model = open_set.popleft()
|
||||
model_label = self.model_label(model)
|
||||
|
||||
if model_label in closed_set:
|
||||
continue
|
||||
|
||||
model_fields = OrderedDict()
|
||||
for field in self.get_fields(model):
|
||||
field_instance = field
|
||||
if not isinstance(field, AKQLField):
|
||||
field_instance = self.get_field_instance(model, field)
|
||||
if not field_instance:
|
||||
continue
|
||||
if isinstance(field_instance, RelationField):
|
||||
open_set.append(field_instance.related_model)
|
||||
model_fields[field_instance.name] = field_instance
|
||||
|
||||
result[model_label] = model_fields
|
||||
closed_set.add(model_label)
|
||||
|
||||
return result
|
||||
|
||||
def get_fields(self, model):
|
||||
"""
|
||||
By default, returns all field names of a given model.
|
||||
|
||||
Override this method to limit field options. You can either return a
|
||||
plain list of field names from it, like ['id', 'name'], or call
|
||||
.super() and exclude unwanted fields from its result.
|
||||
"""
|
||||
return sorted(
|
||||
[f.name for f in model._meta.get_fields() if f.name != "password"],
|
||||
)
|
||||
|
||||
def get_field_instance(self, model, field_name):
|
||||
field = model._meta.get_field(field_name)
|
||||
field_kwargs = {"model": model, "name": field.name}
|
||||
if field.is_relation:
|
||||
if not field.related_model:
|
||||
# GenericForeignKey
|
||||
return
|
||||
if self.excluded(field.related_model):
|
||||
return
|
||||
field_cls = RelationField
|
||||
field_kwargs["related_model"] = field.related_model
|
||||
else:
|
||||
field_cls = self.get_field_cls(field)
|
||||
if isinstance(field, ManyToOneRel | ManyToManyRel | ForeignObjectRel):
|
||||
# Django 1.8 doesn't have .null attribute for these fields
|
||||
field_kwargs["nullable"] = True
|
||||
else:
|
||||
field_kwargs["nullable"] = field.null
|
||||
field_kwargs["suggest_options"] = field.name in self.suggest_options.get(model, [])
|
||||
return field_cls(**field_kwargs)
|
||||
|
||||
def get_field_cls(self, field):
|
||||
str_fields = (
|
||||
models.CharField,
|
||||
models.TextField,
|
||||
models.UUIDField,
|
||||
models.BinaryField,
|
||||
models.GenericIPAddressField,
|
||||
)
|
||||
if isinstance(field, str_fields):
|
||||
return StrField
|
||||
elif isinstance(field, models.AutoField | models.IntegerField):
|
||||
return IntField
|
||||
elif isinstance(field, models.BooleanField | models.NullBooleanField):
|
||||
return BoolField
|
||||
elif isinstance(field, models.DecimalField | models.FloatField):
|
||||
return FloatField
|
||||
elif isinstance(field, models.DateTimeField):
|
||||
return DateTimeField
|
||||
elif isinstance(field, models.DateField):
|
||||
return DateField
|
||||
return AKQLField
|
||||
|
||||
def as_dict(self):
|
||||
from akql.serializers import AKQLSchemaSerializer
|
||||
|
||||
warnings.warn(
|
||||
"DjangoQLSchema.as_dict() is deprecated and will be removed in "
|
||||
"future releases. Please use DjangoQLSchemaSerializer instead.",
|
||||
stacklevel=2,
|
||||
)
|
||||
return AKQLSchemaSerializer().serialize(self)
|
||||
|
||||
def resolve_name(self, name):
|
||||
assert isinstance(name, Name)
|
||||
model = self.model_label(self.current_model)
|
||||
|
||||
root_field = name.parts[0]
|
||||
field = self.models[model].get(root_field)
|
||||
# If the query goes into a JSON field, return the root
|
||||
# field as the JSON field will do the rest
|
||||
if isinstance(field, JSONSearchField):
|
||||
# This is a workaround; build_filter will remove the right-most
|
||||
# entry in the path as that is intended to be the same as the field
|
||||
# however for JSON that is not the case
|
||||
if name.parts[-1] != root_field:
|
||||
name.parts.append(root_field)
|
||||
return field
|
||||
|
||||
for name_part in name.parts:
|
||||
field = self.models[model].get(name_part)
|
||||
if not field:
|
||||
raise AKQLSchemaError(
|
||||
"Unknown field: {}. Possible choices are: {}".format(
|
||||
name_part,
|
||||
", ".join(sorted(self.models[model].keys())),
|
||||
),
|
||||
)
|
||||
if field.type == "relation":
|
||||
model = field.relation
|
||||
field = None
|
||||
return field
|
||||
|
||||
def validate(self, node):
|
||||
"""
|
||||
Validate DjangoQL AST tree vs. current schema
|
||||
"""
|
||||
assert isinstance(node, Node)
|
||||
if isinstance(node.operator, Logical):
|
||||
self.validate(node.left)
|
||||
self.validate(node.right)
|
||||
return
|
||||
assert isinstance(node.left, Name)
|
||||
assert isinstance(node.operator, Comparison)
|
||||
assert isinstance(node.right, Const | List | Variable)
|
||||
|
||||
# Check that field and value types are compatible
|
||||
field = self.resolve_name(node.left)
|
||||
value = node.right.value
|
||||
if field is None:
|
||||
if value is not None:
|
||||
raise AKQLSchemaError(
|
||||
f"Related model {node.left.value} can be compared to None only, but not to "
|
||||
f"{type(value).__name__}",
|
||||
)
|
||||
else:
|
||||
values = value if isinstance(node.right, List) else [value]
|
||||
for v in values:
|
||||
field.validate(v)
|
||||
31
packages/akql/akql/serializers.py
Normal file
31
packages/akql/akql/serializers.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from akql.schema import JSONSearchField, RelationField
|
||||
|
||||
|
||||
class AKQLSchemaSerializer:
|
||||
def serialize(self, schema):
|
||||
models = {}
|
||||
for model_label, fields in schema.models.items():
|
||||
models[model_label] = OrderedDict(
|
||||
[(name, self.serialize_field(f)) for name, f in fields.items()],
|
||||
)
|
||||
return {
|
||||
"current_model": schema.model_label(schema.current_model),
|
||||
"models": models,
|
||||
}
|
||||
|
||||
def serialize_field(self, field):
|
||||
result = {
|
||||
"type": field.type,
|
||||
"nullable": field.nullable,
|
||||
"options": self.serialize_field_options(field),
|
||||
}
|
||||
if isinstance(field, RelationField):
|
||||
result["relation"] = field.relation
|
||||
if isinstance(field, JSONSearchField):
|
||||
result["relation"] = field.relation()
|
||||
return result
|
||||
|
||||
def serialize_field_options(self, field):
|
||||
return list(field.get_options("")) if field.suggest_options else None
|
||||
0
packages/akql/akql/tests/__init__.py
Normal file
0
packages/akql/akql/tests/__init__.py
Normal file
16
packages/akql/akql/tests/test_filter.py
Normal file
16
packages/akql/akql/tests/test_filter.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from akql.queryset import apply_search
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import Notification
|
||||
|
||||
|
||||
class TestFilter(TestCase):
|
||||
|
||||
def test_filter(self):
|
||||
user = create_test_user()
|
||||
notif = Notification.objects.create(user=user)
|
||||
qs = apply_search(
|
||||
Notification.objects.all(), "user.id = $current_user", {"$current_user": user.pk}
|
||||
)
|
||||
self.assertEqual(qs.first(), notif)
|
||||
18
packages/akql/akql/tests/test_lexer.py
Normal file
18
packages/akql/akql/tests/test_lexer.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from akql.lexer import AKQLLexer
|
||||
|
||||
|
||||
class TestLexer(TestCase):
|
||||
|
||||
def test_lexer_simple(self):
|
||||
lexer = AKQLLexer().input('foo = "bar"')
|
||||
tokens = list(str(t) for t in lexer)
|
||||
self.assertEqual(
|
||||
tokens,
|
||||
[
|
||||
"LexToken(NAME,'foo',1,0)",
|
||||
"LexToken(EQUALS,'=',1,4)",
|
||||
"LexToken(STRING_VALUE,'bar',1,6)",
|
||||
],
|
||||
)
|
||||
41
packages/akql/akql/tests/test_parser.py
Normal file
41
packages/akql/akql/tests/test_parser.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from akql.ast import Comparison, Const, Expression, Name, Variable
|
||||
from akql.parser import AKQLParser
|
||||
|
||||
|
||||
class TestParser(TestCase):
|
||||
|
||||
def test_parser_simple(self):
|
||||
ast = AKQLParser().parse('foo = "bar"')
|
||||
self.assertEqual(
|
||||
ast,
|
||||
Expression(
|
||||
left=Name(parts=["foo"]),
|
||||
operator=Comparison(operator="="),
|
||||
right=Const(value="bar"),
|
||||
),
|
||||
)
|
||||
|
||||
def test_parser_not_startswith(self):
|
||||
ast = AKQLParser().parse('foo not startswith "bar"')
|
||||
self.assertEqual(
|
||||
ast,
|
||||
Expression(
|
||||
left=Name(parts=["foo"]),
|
||||
operator=Comparison(operator="not startswith"),
|
||||
right=Const(value="bar"),
|
||||
),
|
||||
)
|
||||
|
||||
def test_parser_variable(self):
|
||||
parser = AKQLParser()
|
||||
ast = parser.parse("foo = $bar")
|
||||
self.assertEqual(
|
||||
ast,
|
||||
Expression(
|
||||
left=Name(parts=["foo"]),
|
||||
operator=Comparison(operator="="),
|
||||
right=Variable(name="$bar", parser=parser),
|
||||
),
|
||||
)
|
||||
51
packages/akql/pyproject.toml
Normal file
51
packages/akql/pyproject.toml
Normal file
@@ -0,0 +1,51 @@
|
||||
[project]
|
||||
name = "akql"
|
||||
version = "3.2.0"
|
||||
description = "Model and object permissions for Django"
|
||||
requires-python = ">=3.9,<3.14"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Authentik Security Inc.", email = "hello@goauthentik.io" },
|
||||
{ name = "Denis Stebunov", email = "support@ivelum.com" },
|
||||
]
|
||||
keywords = ["django", "permissions", "authorization", "object", "row", "level"]
|
||||
|
||||
classifiers = [
|
||||
'Development Status :: 4 - Beta',
|
||||
'Intended Audience :: Developers',
|
||||
'Natural Language :: English',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3.12',
|
||||
'Programming Language :: Python :: 3.13',
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"ply>=3.8",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/goauthentik/authentik/tree/main/packages/akql"
|
||||
Documentation = "https://github.com/goauthentik/authentik/tree/main/packages/akql"
|
||||
Repository = "https://github.com/goauthentik/authentik/tree/main/packages/akql"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = [
|
||||
"akql",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages]
|
||||
find = {}
|
||||
@@ -529,3 +529,7 @@ class _PostgresConsumer(Consumer):
|
||||
conn.close()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
try:
|
||||
connections.close_all()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
|
||||
@@ -47,7 +47,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
go build -o /go/proxy ./cmd/proxy
|
||||
|
||||
# Stage 3: Run
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:07f41ce3f15b2bb5eb5bcd4e6efc0cb42bb7e5609e7244f636da1a91166817ca
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:2f19fc114923ec0842329bf638cb155e597c4be9c8119a3db038ffc3fede9228
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2025.12.0-rc1"
|
||||
version = "2026.2.0-rc1"
|
||||
description = ""
|
||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||
requires-python = "==3.13.*"
|
||||
dependencies = [
|
||||
"ak-guardian==3.2.0",
|
||||
"akql",
|
||||
"argon2-cffi==25.1.0",
|
||||
"channels==4.3.1",
|
||||
"cryptography==45.0.5",
|
||||
@@ -26,7 +27,6 @@ dependencies = [
|
||||
"django-prometheus==2.4.1",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-tenants==3.9.0",
|
||||
"djangoql==0.18.1",
|
||||
"djangorestframework==3.16.1",
|
||||
"docker==7.1.0",
|
||||
"drf-orjson-renderer==1.7.3",
|
||||
@@ -121,6 +121,7 @@ no-binary-package = [
|
||||
|
||||
[tool.uv.sources]
|
||||
ak-guardian = { workspace = true }
|
||||
akql = { workspace = true }
|
||||
django-channels-postgres = { workspace = true }
|
||||
django-dramatiq-postgres = { workspace = true }
|
||||
django-postgres-cache = { workspace = true }
|
||||
@@ -129,6 +130,7 @@ opencontainers = { git = "https://github.com/vsoch/oci-python", rev = "ceb4fcc09
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"packages/ak-guardian",
|
||||
"packages/akql",
|
||||
"packages/django-channels-postgres",
|
||||
"packages/django-dramatiq-postgres",
|
||||
"packages/django-postgres-cache",
|
||||
|
||||
@@ -31,7 +31,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
go build -o /go/radius ./cmd/radius
|
||||
|
||||
# Stage 2: Run
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:07f41ce3f15b2bb5eb5bcd4e6efc0cb42bb7e5609e7244f636da1a91166817ca
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:2f19fc114923ec0842329bf638cb155e597c4be9c8119a3db038ffc3fede9228
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
|
||||
26
schema.yml
26
schema.yml
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2025.12.0-rc1
|
||||
version: 2026.2.0-rc1
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@@ -4728,11 +4728,6 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
description: Only return certificate-key pairs with keys
|
||||
- in: query
|
||||
name: include_details
|
||||
schema:
|
||||
type: boolean
|
||||
default: true
|
||||
- in: query
|
||||
name: key_type
|
||||
schema:
|
||||
@@ -34992,25 +34987,25 @@ components:
|
||||
type: string
|
||||
fingerprint_sha256:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Get certificate Hash (SHA256)
|
||||
readOnly: true
|
||||
nullable: true
|
||||
description: SHA256 fingerprint of the certificate
|
||||
fingerprint_sha1:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Get certificate Hash (SHA1)
|
||||
readOnly: true
|
||||
nullable: true
|
||||
description: SHA1 fingerprint of the certificate
|
||||
cert_expiry:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Get certificate expiry
|
||||
readOnly: true
|
||||
nullable: true
|
||||
description: Certificate expiry date
|
||||
cert_subject:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Get certificate subject as full rfc4514
|
||||
readOnly: true
|
||||
nullable: true
|
||||
description: Certificate subject as RFC4514 string
|
||||
private_key_available:
|
||||
type: boolean
|
||||
description: Show if this keypair has a private key configured or not
|
||||
@@ -35018,8 +35013,9 @@ components:
|
||||
key_type:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/KeyTypeEnum'
|
||||
nullable: true
|
||||
readOnly: true
|
||||
nullable: true
|
||||
description: Key algorithm type detected from the certificate's public key
|
||||
certificate_download_url:
|
||||
type: string
|
||||
description: Get URL to download certificate
|
||||
|
||||
@@ -23,8 +23,10 @@ from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import (
|
||||
DetachedShadowRootException,
|
||||
NoSuchElementException,
|
||||
NoSuchShadowRootException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
WebDriverException,
|
||||
)
|
||||
@@ -326,18 +328,23 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
|
||||
while attempts < SHADOW_ROOT_RETRIES:
|
||||
try:
|
||||
host = container.find_element(By.CSS_SELECTOR, selector)
|
||||
return host.shadow_root
|
||||
except NoSuchShadowRootException:
|
||||
except (
|
||||
NoSuchElementException,
|
||||
NoSuchShadowRootException,
|
||||
DetachedShadowRootException,
|
||||
StaleElementReferenceException,
|
||||
):
|
||||
attempts += 1
|
||||
sleep(0.2)
|
||||
# re-find host in case it was re-attached
|
||||
try:
|
||||
host = container.find_element(By.CSS_SELECTOR, selector)
|
||||
except NoSuchElementException:
|
||||
# loop and retry finding host
|
||||
pass
|
||||
|
||||
inner_html = host.get_attribute("innerHTML") or "<no host>"
|
||||
inner_html = "<no host>"
|
||||
if host is not None:
|
||||
try:
|
||||
inner_html = host.get_attribute("innerHTML") or "<no host>"
|
||||
except (DetachedShadowRootException, StaleElementReferenceException):
|
||||
inner_html = "<stale host>"
|
||||
|
||||
raise RuntimeError(
|
||||
f"Failed to obtain shadow root for {selector} after {attempts} attempts. "
|
||||
|
||||
29
uv.lock
generated
29
uv.lock
generated
@@ -5,6 +5,7 @@ requires-python = "==3.13.*"
|
||||
[manifest]
|
||||
members = [
|
||||
"ak-guardian",
|
||||
"akql",
|
||||
"authentik",
|
||||
"django-channels-postgres",
|
||||
"django-dramatiq-postgres",
|
||||
@@ -93,6 +94,17 @@ requires-dist = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.15'", specifier = ">=4.12.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "akql"
|
||||
version = "3.2.0"
|
||||
source = { editable = "packages/akql" }
|
||||
dependencies = [
|
||||
{ name = "ply" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "ply", specifier = ">=3.8" }]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
@@ -185,10 +197,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2025.12.0rc1"
|
||||
version = "2026.2.0rc1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ak-guardian" },
|
||||
{ name = "akql" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "channels" },
|
||||
{ name = "cryptography" },
|
||||
@@ -209,7 +222,6 @@ dependencies = [
|
||||
{ name = "django-prometheus" },
|
||||
{ name = "django-storages", extra = ["s3"] },
|
||||
{ name = "django-tenants" },
|
||||
{ name = "djangoql" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "docker" },
|
||||
{ name = "drf-orjson-renderer" },
|
||||
@@ -293,6 +305,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "ak-guardian", editable = "packages/ak-guardian" },
|
||||
{ name = "akql", editable = "packages/akql" },
|
||||
{ name = "argon2-cffi", specifier = "==25.1.0" },
|
||||
{ name = "channels", specifier = "==4.3.1" },
|
||||
{ name = "cryptography", specifier = "==45.0.5" },
|
||||
@@ -313,7 +326,6 @@ requires-dist = [
|
||||
{ name = "django-prometheus", specifier = "==2.4.1" },
|
||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||
{ name = "django-tenants", specifier = "==3.9.0" },
|
||||
{ name = "djangoql", specifier = "==0.18.1" },
|
||||
{ name = "djangorestframework", specifier = "==3.16.1" },
|
||||
{ name = "docker", specifier = "==7.1.0" },
|
||||
{ name = "drf-orjson-renderer", specifier = "==1.7.3" },
|
||||
@@ -1252,17 +1264,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/918cfca627fcdc3441981dddc72a22be02e57abdb5391eb7339ea77a5ef4/django_tenants-3.9.0-py3-none-any.whl", hash = "sha256:14421088a4336444e2c4af54f21a6af2e57e53dcf95ba5d19b5fa17142cb460b", size = 215955, upload-time = "2025-09-06T21:46:05.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangoql"
|
||||
version = "0.18.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ply" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/0a/83cdb7b9d3b854b98941363153945f6c051b3bc50cd61108a85677c98c3a/djangoql-0.18.1-py2.py3-none-any.whl", hash = "sha256:51b3085a805627ebb43cfd0aa861137cdf8f69cc3c9244699718fe04a6c8e26d", size = 218209, upload-time = "2024-01-08T14:10:47.915Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.16.1"
|
||||
|
||||
260
web/package-lock.json
generated
260
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2026.2.0-rc1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2026.2.0-rc1",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
@@ -44,10 +44,10 @@
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sentry/browser": "^10.29.0",
|
||||
"@storybook/addon-docs": "^10.1.6",
|
||||
"@storybook/addon-links": "^10.1.6",
|
||||
"@storybook/web-components": "^10.1.6",
|
||||
"@storybook/web-components-vite": "^10.1.6",
|
||||
"@storybook/addon-docs": "^10.1.7",
|
||||
"@storybook/addon-links": "^10.1.7",
|
||||
"@storybook/web-components": "^10.1.7",
|
||||
"@storybook/web-components-vite": "^10.1.7",
|
||||
"@types/codemirror": "^5.60.17",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/guacamole-common-js": "^1.5.5",
|
||||
@@ -63,6 +63,7 @@
|
||||
"change-case": "^5.4.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chromedriver": "143.0.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"core-js": "^3.47.0",
|
||||
"country-flag-icons": "^1.6.4",
|
||||
@@ -91,8 +92,8 @@
|
||||
"prettier": "^3.6.2",
|
||||
"pseudolocale": "^2.2.0",
|
||||
"rapidoc": "^9.3.8",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-mermaid": "^3.0.0",
|
||||
"rehype-parse": "^9.0.1",
|
||||
@@ -127,7 +128,7 @@
|
||||
"@rollup/rollup-darwin-arm64": "^4.53.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.53.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.53.3",
|
||||
"chromedriver": "^143.0.0"
|
||||
"chromedriver": "^143.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -3170,15 +3171,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.1.6.tgz",
|
||||
"integrity": "sha512-+/hTCxh+qTgAmUKkGGwD3oQ+VKj9Li8TTU0jQl8tcUxX1490fo0q2Eov8dOnfV66cxHxd3RgKaB4KHOaHoj0jQ==",
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.1.7.tgz",
|
||||
"integrity": "sha512-RNwz5jDjBhjST70BoxUCYVfT2sexTKsDSN2FcnBBJ2/sAtjKbTpX3p4PfFaeFqwhDc+6TCBUTxfO4BsAQXf5jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.1.6",
|
||||
"@storybook/csf-plugin": "10.1.7",
|
||||
"@storybook/icons": "^2.0.0",
|
||||
"@storybook/react-dom-shim": "10.1.6",
|
||||
"@storybook/react-dom-shim": "10.1.7",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -3188,13 +3189,13 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.1.6"
|
||||
"storybook": "^10.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.1.6.tgz",
|
||||
"integrity": "sha512-koOvo7ny1TCVkZ9WCJ3PoOwWOv+mK5UOcWzAuiYf2LBAvMyOObX89dNdjvu+R77J4mRxY45XHJF0tgAzGKeHNQ==",
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.1.7.tgz",
|
||||
"integrity": "sha512-Tgfa4FN3id8AoxBk0JbdqtWqEF8ky1FQeC3bkl7TACib9pUQfyvyvtFZKBw4lfOS1LvmrhrGy5cVp7GHmQ1Kfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0"
|
||||
@@ -3205,7 +3206,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.1.6"
|
||||
"storybook": "^10.1.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
@@ -3214,12 +3215,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/builder-vite": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.6.tgz",
|
||||
"integrity": "sha512-dXfpSFmg8thg3uVCbZMPR36W36Ktd1MBW6Rl3rQOzDWaV2v0Qbp3s0QOgI8VIJ22L+JGN1TlSgsU2FMzH5xDKw==",
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.7.tgz",
|
||||
"integrity": "sha512-UKmym/o20SJFYjbt/X1j39vORXwC1lkGHKD9JlR8UAwkRuGOoEktUIYYfB7cmrOKjdgf3Es8/SIu+lgbWWleew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/csf-plugin": "10.1.6",
|
||||
"@storybook/csf-plugin": "10.1.7",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"ts-dedent": "^2.0.0"
|
||||
},
|
||||
@@ -3228,14 +3229,14 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.1.6",
|
||||
"storybook": "^10.1.7",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.6.tgz",
|
||||
"integrity": "sha512-PAxzfiPCJiEZx/u2AfJ85u+2XpNVs8Aw+MgECpZdFMcX7jUP21MtfAu5L+9sehv1HomFsytbuO+D3C3IkdJRrw==",
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.7.tgz",
|
||||
"integrity": "sha512-mUWM3kFSQpm4At6+OJmmqiezjEdq+y9HD2abuiCVvnTDf7ftoMcv4EbKqf6DM5CXcOpqRDlRxwzEum+hbfh5ig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unplugin": "^2.3.5"
|
||||
@@ -3247,7 +3248,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "^10.1.6",
|
||||
"storybook": "^10.1.7",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -3283,9 +3284,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.6.tgz",
|
||||
"integrity": "sha512-hJI+mIDKioKMWL8YH32alkULmUW6A1iOljghF6fSLYI2TtGdfMLnoXogEnb0o11J8zhMUBXORrOGO0UL2+T69g==",
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.7.tgz",
|
||||
"integrity": "sha512-cjIoNbWnGiet3vRjswnnh3ioN+X2ZEqDBIV6b+WN8RpGSUs3vg6V2s7G8IzgSfxFDahIqQ6D7yot3aOOlN+qBw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3294,13 +3295,13 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.1.6"
|
||||
"storybook": "^10.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/web-components": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-10.1.6.tgz",
|
||||
"integrity": "sha512-3ACRNRqld88Ev5tVnzUZ1QrHmbtnFe+Hoqe/YJ0LbXHYQU0b6vFGUGgf42Wf2R7A2tfNbDGiqjaHuIkrJdhklA==",
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-10.1.7.tgz",
|
||||
"integrity": "sha512-qHvIQvVEj4rFBUFA12Y0UAWdlrzHRaEfs2KFXIEciUt1Gjz3VBkl8YSTWUZtrpl4vTYh4GCqne0oZst4lT8DZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
@@ -3313,24 +3314,24 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^2.0.0 || ^3.0.0",
|
||||
"storybook": "^10.1.6"
|
||||
"storybook": "^10.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/web-components-vite": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/web-components-vite/-/web-components-vite-10.1.6.tgz",
|
||||
"integrity": "sha512-f/EDYXgzdE5v+qWcuIj9a6d+noG7rpad91St7hCQfleLvo9GBDAIZz6H87VReMjP/u4Bl3EOdcLOoAeU+dG0ZQ==",
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/web-components-vite/-/web-components-vite-10.1.7.tgz",
|
||||
"integrity": "sha512-TEDD0GSelmPGw0me1cWZI/uzzwGbfJT0uDFwNzrOp72FKvcJ9BWeO9H4fDjtcPSORjsWkXHZ+UAelv1JHUzFHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-vite": "10.1.6",
|
||||
"@storybook/web-components": "10.1.6"
|
||||
"@storybook/builder-vite": "10.1.7",
|
||||
"@storybook/web-components": "10.1.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.1.6"
|
||||
"storybook": "^10.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@swagger-api/apidom-ast": {
|
||||
@@ -6139,6 +6140,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"run-applescript": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-lookup": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
|
||||
@@ -6439,9 +6455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chromedriver": {
|
||||
"version": "143.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-143.0.0.tgz",
|
||||
"integrity": "sha512-zsDjk9nLeQsMcnFXP4huqCOqneIdox3ECIR4laBOH7sog1+K2rTgrK60ogSeYaHUnx9OTAMFkwJL29ekVgVQgw==",
|
||||
"version": "143.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-143.0.1.tgz",
|
||||
"integrity": "sha512-qR0DKGV7C3r5Fo6WJD2bqQ1ny9THrJTL4X8JeArBQ6m5Cq6sHXAhIrVgsTZuKi9tL/YUcFoELMI3UTg6dMiKWA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
@@ -7375,6 +7391,34 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
|
||||
"integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
"default-browser-id": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser-id": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
|
||||
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defaults": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-2.0.2.tgz",
|
||||
@@ -7413,6 +7457,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
@@ -10104,6 +10160,21 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -10178,6 +10249,24 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-inside-container": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
|
||||
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"is-inside-container": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-map": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
||||
@@ -10436,6 +10525,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
|
||||
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-inside-container": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is2": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/is2/-/is2-2.0.9.tgz",
|
||||
@@ -12990,6 +13094,24 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.2.1",
|
||||
"define-lazy-prop": "^3.0.0",
|
||||
"is-inside-container": "^1.0.0",
|
||||
"wsl-utils": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-path-templating": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz",
|
||||
@@ -13927,9 +14049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -13937,16 +14059,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1"
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
@@ -14551,6 +14673,18 @@
|
||||
"points-on-path": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -15225,9 +15359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.6.tgz",
|
||||
"integrity": "sha512-IK3iJvOi5rKJzudNN3KDnKu3YPY4WtVZOXU/POBaA/S+J4n3QcDT2XEysm27dLZZQVC8sMSCOqIM83HImIeh0g==",
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.7.tgz",
|
||||
"integrity": "sha512-dK1p2LKzAdea60APGo/vMbF+X/D7eVZsv8ijnLVvfMBjScdDBgxfIn025mRtOwqECb/UN9cIpPs5XEWAeLpYMg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -15238,6 +15372,7 @@
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0",
|
||||
"open": "^10.2.0",
|
||||
"recast": "^0.23.5",
|
||||
"semver": "^7.6.2",
|
||||
"use-sync-external-store": "^1.5.0",
|
||||
@@ -17596,6 +17731,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-but-prettier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml-but-prettier/-/xml-but-prettier-1.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2026.2.0-rc1",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -118,10 +118,10 @@
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sentry/browser": "^10.29.0",
|
||||
"@storybook/addon-docs": "^10.1.6",
|
||||
"@storybook/addon-links": "^10.1.6",
|
||||
"@storybook/web-components": "^10.1.6",
|
||||
"@storybook/web-components-vite": "^10.1.6",
|
||||
"@storybook/addon-docs": "^10.1.7",
|
||||
"@storybook/addon-links": "^10.1.7",
|
||||
"@storybook/web-components": "^10.1.7",
|
||||
"@storybook/web-components-vite": "^10.1.7",
|
||||
"@types/codemirror": "^5.60.17",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/guacamole-common-js": "^1.5.5",
|
||||
@@ -165,8 +165,8 @@
|
||||
"prettier": "^3.6.2",
|
||||
"pseudolocale": "^2.2.0",
|
||||
"rapidoc": "^9.3.8",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-mermaid": "^3.0.0",
|
||||
"rehype-parse": "^9.0.1",
|
||||
@@ -197,7 +197,7 @@
|
||||
"@rollup/rollup-darwin-arm64": "^4.53.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.53.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.53.3",
|
||||
"chromedriver": "^143.0.0"
|
||||
"chromedriver": "^143.0.1"
|
||||
},
|
||||
"wireit": {
|
||||
"build": {
|
||||
|
||||
@@ -99,6 +99,9 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
|
||||
const alertMsg = msg(
|
||||
"Using this form will only create an Application. In order to authenticate with the application, you will have to manually pair it with a Provider.",
|
||||
);
|
||||
const providerFromInstance = this.instance?.provider;
|
||||
const providerValue = providerFromInstance ?? this.provider;
|
||||
const providerPrefilled = !this.instance && this.provider !== undefined;
|
||||
|
||||
return html`
|
||||
${this.instance ? nothing : html`<ak-alert level="pf-m-info">${alertMsg}</ak-alert>`}
|
||||
@@ -134,9 +137,10 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
|
||||
<ak-provider-search-input
|
||||
name="provider"
|
||||
label=${msg("Provider")}
|
||||
value=${ifPresent(this.instance?.provider)}
|
||||
.value=${providerValue}
|
||||
.readOnly=${providerPrefilled}
|
||||
?blankable=${!providerPrefilled}
|
||||
help=${msg("Select a provider that this application should use.")}
|
||||
blankable
|
||||
></ak-provider-search-input>
|
||||
<ak-backchannel-providers-input
|
||||
name="backchannelProviders"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/ap
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
const renderElement = (item: Provider) => item.name;
|
||||
const renderValue = (item: Provider | undefined) => item?.pk;
|
||||
@@ -53,6 +54,9 @@ export class AkProviderInput extends AKElement {
|
||||
@property({ type: Number })
|
||||
value?: number;
|
||||
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
readOnly = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@@ -76,6 +80,8 @@ export class AkProviderInput extends AKElement {
|
||||
};
|
||||
|
||||
render() {
|
||||
const readOnlyValue = this.readOnly && typeof this.value === "number";
|
||||
|
||||
return html` <ak-form-element-horizontal name=${this.name}>
|
||||
${AKLabel(
|
||||
{
|
||||
@@ -86,7 +92,9 @@ export class AkProviderInput extends AKElement {
|
||||
},
|
||||
this.label,
|
||||
)}
|
||||
|
||||
${readOnlyValue
|
||||
? html`<input type="hidden" name=${this.name} value=${this.value ?? ""} />`
|
||||
: nothing}
|
||||
<ak-search-select
|
||||
.fieldID=${this.fieldID}
|
||||
.selected=${this.#selected}
|
||||
@@ -94,7 +102,9 @@ export class AkProviderInput extends AKElement {
|
||||
.renderElement=${renderElement}
|
||||
.value=${renderValue}
|
||||
.groupBy=${doGroupBy}
|
||||
?blankable=${!!this.blankable}
|
||||
?blankable=${readOnlyValue ? false : !!this.blankable}
|
||||
?readonly=${this.readOnly}
|
||||
name=${ifDefined(readOnlyValue ? undefined : this.name)}
|
||||
>
|
||||
</ak-search-select>
|
||||
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
|
||||
|
||||
@@ -67,24 +67,9 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
@property({ type: Boolean, attribute: "singleton" })
|
||||
public singleton = false;
|
||||
|
||||
/**
|
||||
* Set to `true` to include certificate details (fingerprints, expiry, certificate subject, key type)
|
||||
* in the API response.
|
||||
* Each returned certificate's PEM data must be parsed using cryptography library,
|
||||
* public keys extracted, and hashes computed. With large result sets, this can add a lot of time
|
||||
* to responses.
|
||||
* Only enable when you actually need the detailed fields displayed in the UI.
|
||||
* For simple certificate selection dropdowns, leave this as `false` (default).
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "include-details" })
|
||||
public includeDetails = false;
|
||||
|
||||
/**
|
||||
* When allowedKeyTypes is set, only certificates or keypairs with matching
|
||||
* key algorithms will be shown. Since certificates must be parsed to
|
||||
* extract algorithm details, an instance with many certificates may experience
|
||||
* long delays and server performance slowdowns. Avoid setting this field whenever possible.
|
||||
* key algorithms will be shown.
|
||||
* @attr
|
||||
* @example [KeyTypeEnum.Rsa, KeyTypeEnum.Ec]
|
||||
*/
|
||||
@@ -123,7 +108,6 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
const args: CryptoCertificatekeypairsListRequest = {
|
||||
ordering: "name",
|
||||
hasKey: !this.noKey,
|
||||
includeDetails: this.includeDetails,
|
||||
};
|
||||
if (query !== undefined) {
|
||||
args.search = query;
|
||||
|
||||
@@ -45,9 +45,9 @@ export class CertificateKeyPairListPage extends TablePage<CertificateKeyPair> {
|
||||
static styles: CSSResult[] = [...super.styles, PFDescriptionList];
|
||||
|
||||
async apiEndpoint(): Promise<PaginatedResponse<CertificateKeyPair>> {
|
||||
return new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList(
|
||||
await this.defaultEndpointConfig(),
|
||||
);
|
||||
return new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList({
|
||||
...(await this.defaultEndpointConfig()),
|
||||
});
|
||||
}
|
||||
|
||||
protected columns: TableColumn[] = [
|
||||
|
||||
@@ -23,6 +23,7 @@ type Group<T> = [string, T[]];
|
||||
|
||||
export interface ISearchSelectBase<T> {
|
||||
blankable?: boolean;
|
||||
readOnly?: boolean;
|
||||
query?: string;
|
||||
objects?: T[];
|
||||
selectedObject: T | null;
|
||||
@@ -93,6 +94,14 @@ export abstract class SearchSelectBase<T>
|
||||
@property({ type: Boolean })
|
||||
public creatable?: boolean;
|
||||
|
||||
/**
|
||||
* Prevent user interaction while still rendering the current value.
|
||||
* @property
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
public readOnly = false;
|
||||
|
||||
/**
|
||||
* An initial string to filter the search contents,
|
||||
* and the value of the input which further serves to restrict the search.
|
||||
@@ -254,6 +263,8 @@ export abstract class SearchSelectBase<T>
|
||||
}
|
||||
|
||||
#searchListener = (event: InputEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
const value = (event.target as SearchSelectView).rawValue;
|
||||
|
||||
if (!value) {
|
||||
@@ -277,6 +288,8 @@ export abstract class SearchSelectBase<T>
|
||||
};
|
||||
|
||||
private onSelect(event: InputEvent) {
|
||||
if (this.readOnly) return;
|
||||
|
||||
const value = (event.target as SearchSelectView).value;
|
||||
|
||||
if (!value) {
|
||||
@@ -381,6 +394,7 @@ export abstract class SearchSelectBase<T>
|
||||
.options=${options}
|
||||
value=${ifPresent(value)}
|
||||
?blankable=${this.blankable}
|
||||
?readonly=${this.readOnly}
|
||||
label=${ifPresent(this.label)}
|
||||
name=${ifPresent(this.name)}
|
||||
placeholder=${ifPresent(this.placeholder)}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ISearchSelectView {
|
||||
value?: string;
|
||||
open: boolean;
|
||||
blankable: boolean;
|
||||
readOnly: boolean;
|
||||
caseSensitive: boolean;
|
||||
name?: string;
|
||||
placeholder: string;
|
||||
@@ -126,6 +127,14 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
@property({ type: Boolean })
|
||||
public blankable = false;
|
||||
|
||||
/**
|
||||
* Prevents user interaction while showing the current value.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
public readOnly = false;
|
||||
|
||||
/**
|
||||
* If not managed, make the matcher case-sensitive during interaction. If managed,
|
||||
* the manager must handle this.
|
||||
@@ -248,6 +257,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
//#region Event Listeners
|
||||
|
||||
#clickListener = (_ev: Event) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
this.open = !this.open;
|
||||
this.#inputRef.value?.focus();
|
||||
};
|
||||
@@ -263,6 +274,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
}
|
||||
|
||||
#searchKeyupListener = (event: KeyboardEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -277,6 +290,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
};
|
||||
|
||||
#searchKeydownListener = (event: KeyboardEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (!this.open) return;
|
||||
|
||||
switch (event.key) {
|
||||
@@ -339,6 +354,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
}
|
||||
|
||||
#inputListener = (_ev: InputEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (!this.managed) {
|
||||
this.findValueForInput();
|
||||
this.requestUpdate();
|
||||
@@ -356,6 +373,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
};
|
||||
|
||||
#listKeydownListener = (event: KeyboardEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -364,6 +383,8 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
};
|
||||
|
||||
#changeListener = (event: InputEvent) => {
|
||||
if (this.readOnly) return;
|
||||
|
||||
if (!event.target) {
|
||||
return;
|
||||
}
|
||||
@@ -441,6 +462,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
@keyup=${this.#searchKeyupListener}
|
||||
@keydown=${this.#searchKeydownListener}
|
||||
value=${this.displayValue}
|
||||
?readonly=${this.readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7893,10 +7893,6 @@ neprojde, když jedna nebo obě z vybraných možností jsou rovny nebo nad prah
|
||||
<source>Send notification to event user</source>
|
||||
<target>Zasílat oznámení původci události</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>Pokud je povoleno, oznámení se kromě uživatelů ve skupině výše odešle také původci události. Uživatel, který událost vyvolal, je vždy prvním příjemcem; pokud má být oznámení doručeno pouze jednou, je třeba v transportu notifikací zapnout volbu ‘Poslat jednou’.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Transporty</target>
|
||||
@@ -8086,10 +8082,6 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
|
||||
<source>Local</source>
|
||||
<target>Místní</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Pokud je povoleno, použije se místní připojení. Vyžaduje Docker socket/Kubernetes integraci.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker URL</target>
|
||||
@@ -9801,9 +9793,6 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9900,6 +9889,18 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7927,10 +7927,6 @@ Beim Erstellen eines festen Auswahlfelds aktiviere „Als Ausdruck interpretiere
|
||||
<source>Send notification to event user</source>
|
||||
<target>Benachrichtigung an Ereignisbenutzer senden</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>Wenn aktiviert, wird zusätzlich zu den Benutzern der oben ausgewählten Gruppe auch der Benutzer benachrichtigt, der das Ereignis ausgelöst hat. Der Ereignisbenutzer wird immer als erster benachrichtigt. Um eine Benachrichtigung nur an den Ereignisbenutzer zu senden, aktiviere „Einmal senden“ im Benachrichtigungstransport.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Zustellungsarten</target>
|
||||
@@ -8120,10 +8116,6 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
|
||||
<source>Local</source>
|
||||
<target>Lokal</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Nutze, wenn aktiviert, die lokale Verbindung. Benötigt Docker socket/Kubernetes Integration.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker URL</target>
|
||||
@@ -9841,9 +9833,6 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9940,6 +9929,18 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -6085,9 +6085,6 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s47966b2a708694e2">
|
||||
<source>Send notification to event user</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
</trans-unit>
|
||||
@@ -6227,9 +6224,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="se9b1fec72ffd8f48">
|
||||
<source>Local</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
</trans-unit>
|
||||
@@ -7632,9 +7626,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -7731,6 +7722,18 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7855,10 +7855,6 @@ El valor de este campo se compara con el atributo de pertenencia del usuario.</t
|
||||
<source>Send notification to event user</source>
|
||||
<target>Enviar notificación al usuario del evento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>Cuando está habilitado, se enviará una notificación al usuario que desencadenó el evento, además de a cualquier usuario en el grupo mencionado anteriormente. El usuario del evento siempre será el primer usuario; para enviar una notificación solo a este usuario, active 'Enviar una vez' en el transporte de notificaciones.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Medios</target>
|
||||
@@ -8048,10 +8044,6 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
|
||||
<source>Local</source>
|
||||
<target>Local</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Si está habilitada, use la conexión local. Se requiere la integración de Docker Socket/Kubernetes.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>URL de Docker</target>
|
||||
@@ -9761,9 +9753,6 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9860,6 +9849,18 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -8058,10 +8058,6 @@ läpäisy estyy kun jompi kumpi tai molemmat vaihtoehdot ylittävät raja-arvon.
|
||||
<source>Send notification to event user</source>
|
||||
<target>Lähetä notifikaatiot tapahtuman käyttäjälle</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>Kun tämä on käytössä, notifikaatio lähetetään sille käyttäjälle, joka laukaisi tapahtuman, niiden käyttäjien lisäksi, jotka ovat yllä olevan ryhmän jäseniä. Tapahtuman käyttäjä on aina ensimmäinen käyttäjä. Lähettääksesi notifikaation vain tapahtuman käyttäjälle, valitse 'Lähetä kerran' notifikaation väylän asetuksissa.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Väylät</target>
|
||||
@@ -8251,10 +8247,6 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
|
||||
<source>Local</source>
|
||||
<target>Paikallinen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Jos käytössä, käytä paikallista yhteyttä. Pakollinen Docker socket/Kubernetes-integraatio.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker URL</target>
|
||||
@@ -10024,9 +10016,6 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -10123,6 +10112,18 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -8048,10 +8048,6 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<source>Send notification to event user</source>
|
||||
<target>Envoyer la notification à l'utilisateur associé à l'événement</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>Lorsque cette option est activée, une notification sera envoyée à l'utilisateur qui a déclenché l'événement en plus des utilisateurs du groupe ci-dessus. L'utilisateur associé à l'événement sera toujours le premier utilisateur. Pour envoyer une notification uniquement à l'utilisateur de l'événement, activez l'option "Envoyer une seule fois" dans le transport de notification.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Transports</target>
|
||||
@@ -8241,10 +8237,6 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
|
||||
<source>Local</source>
|
||||
<target>Local</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Si activé, utiliser la connexion locale. Intégration Docker socket/Kubernetes requise.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>URL Docker</target>
|
||||
@@ -10009,9 +10001,6 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -10108,6 +10097,18 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7810,10 +7810,6 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<source>Send notification to event user</source>
|
||||
<target>Invia notifica all'utente dell'evento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>Se abilitata, la notifica verrà inviata all'utente che ha attivato l'evento, oltre a tutti gli utenti del gruppo sopra indicato. L'utente dell'evento sarà sempre il primo a inviare una notifica solo all'utente dell'evento che ha abilitato "Invia una volta" nel trasporto delle notifiche.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Trasporti</target>
|
||||
@@ -8003,10 +7999,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<source>Local</source>
|
||||
<target>Locale</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Se abilitato, utilizzare la connessione locale. Integrazione Docker Cocket Docker/Kubernetes.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker URL</target>
|
||||
@@ -9709,9 +9701,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9808,6 +9797,18 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -8046,11 +8046,6 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<source>Send notification to event user</source>
|
||||
<target>イベントユーザーに通知を送信</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>有効になると、イベントをトリガーしたユーザーと上記のグループ内のユーザーに通知が送信されます。イベントユーザーは常に最初のユーザーになります。イベントユーザーにのみ通知を送信するには、通知トランスポートで「1
|
||||
回だけ送信」を有効にします。</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>トランスポート</target>
|
||||
@@ -8236,10 +8231,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<source>Local</source>
|
||||
<target>ローカル</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>有効にすると、ローカル接続を使用します。Docker ソケット/Kubernetes 統合が必要です。</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker URL</target>
|
||||
@@ -10004,9 +9995,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -10103,6 +10091,18 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7508,9 +7508,6 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s47966b2a708694e2">
|
||||
<source>Send notification to event user</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>전송</target>
|
||||
@@ -7692,10 +7689,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<source>Local</source>
|
||||
<target>로컬</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>활성화된 경우, 로컬 연결을 사용합니다. 도커/쿠버네티스 통합에 필수적입니다.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>도커 URL</target>
|
||||
@@ -9333,9 +9326,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9432,6 +9422,18 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7226,9 +7226,6 @@ slaagt niet wanneer een of beide geselecteerde opties gelijk zijn aan of boven d
|
||||
<trans-unit id="s47966b2a708694e2">
|
||||
<source>Send notification to event user</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Transporten</target>
|
||||
@@ -7405,10 +7402,6 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
|
||||
<source>Local</source>
|
||||
<target>Lokaal</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Indien ingeschakeld, gebruik de lokale verbinding. Vereist Docker-socket/Kubernetes-integratie.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker-URL</target>
|
||||
@@ -8981,9 +8974,6 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9080,6 +9070,18 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7530,9 +7530,6 @@ w toku, tworzony jest nowy użytkownik i zapisywane są do niego dane.</target>
|
||||
<trans-unit id="s47966b2a708694e2">
|
||||
<source>Send notification to event user</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Transporty</target>
|
||||
@@ -7719,10 +7716,6 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
|
||||
<source>Local</source>
|
||||
<target>Lokalny</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Jeśli jest włączone, użyj połączenia lokalnego. Wymagane socket Docker/Integracja Kubernetes.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>URL Dockera</target>
|
||||
@@ -9363,9 +9356,6 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9462,6 +9452,18 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -8043,10 +8043,6 @@ retorne uma lista para fornecer várias opções padrão.</target>
|
||||
<source>Send notification to event user</source>
|
||||
<target>Enviar notificação para o usuário do evento</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>Quando ativado, a notificação será enviada ao usuário que acionou o evento, além de quaisquer usuários no grupo acima. O usuário do evento será sempre o primeiro usuário; para enviar uma notificação apenas ao usuário do evento, ative 'Enviar uma vez' no transporte de notificação.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Transportes</target>
|
||||
@@ -8233,10 +8229,6 @@ As vinculações a grupos/usuários são verificadas em relação ao usuário do
|
||||
<source>Local</source>
|
||||
<target>Local</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Se ativado, use a conexão local. Requer integração com Docker socket/Kubernetes.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>URL do Docker</target>
|
||||
@@ -9992,9 +9984,6 @@ por exemplo: <x id="0" equiv-text="<code>"/>oci://registry.domain.tld/path
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -10091,6 +10080,18 @@ por exemplo: <x id="0" equiv-text="<code>"/>oci://registry.domain.tld/path
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7588,9 +7588,6 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s47966b2a708694e2">
|
||||
<source>Send notification to event user</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Поставщики</target>
|
||||
@@ -7777,10 +7774,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<source>Local</source>
|
||||
<target>Местный</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Если включено, используется локальное соединение. Требует Docker сокет/Kubernetes интеграции.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>URL-адрес Docker</target>
|
||||
@@ -9451,9 +9444,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9550,6 +9540,18 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7578,9 +7578,6 @@ Belirlenen seçeneklerden biri veya her ikisi de eşiğe eşit veya eşiğin üz
|
||||
<trans-unit id="s47966b2a708694e2">
|
||||
<source>Send notification to event user</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>Aktarıcılar</target>
|
||||
@@ -7766,10 +7763,6 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
|
||||
<source>Local</source>
|
||||
<target>Yerel</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>Etkinleştirilirse, yerel bağlantıyı kullanın. Gerekli Docker soketi/Kubernetes Entegrasyonu.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker URL'si</target>
|
||||
@@ -9429,9 +9422,6 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9528,6 +9518,18 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -8010,10 +8010,6 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<source>Send notification to event user</source>
|
||||
<target>发送通知给事件用户</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
<target>启用时,通知不仅会发送给触发事件的用户,还会发送到组中的任何用户。事件用户将总是第一个用户,要只向事件用户发送通知,则需要在通知传输中启用“发送一次”。</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>传输</target>
|
||||
@@ -8203,10 +8199,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<source>Local</source>
|
||||
<target>本地</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>如果启用,请使用本地连接。需要 Docker Socket/Kubernetes 集成。</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker URL</target>
|
||||
@@ -9964,9 +9956,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -10063,6 +10052,18 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -7292,9 +7292,6 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s47966b2a708694e2">
|
||||
<source>Send notification to event user</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sd30f00ff2135589c">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sffa171e11d4ae513">
|
||||
<source>Transports</source>
|
||||
<target>通道</target>
|
||||
@@ -7470,10 +7467,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<source>Local</source>
|
||||
<target>本機端連線</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sc1231049879b8d33">
|
||||
<source>If enabled, use the local connection. Required Docker socket/Kubernetes Integration.</source>
|
||||
<target>啟用時,請使用本機連線。需要整合 docker / Kubernetes 的 socket。</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13de04774ff0f210">
|
||||
<source>Docker URL</source>
|
||||
<target>Docker 網址</target>
|
||||
@@ -9044,9 +9037,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s7dcfe418b8d601f6">
|
||||
<source>Flags allow you to enable new functionality and behaviour in authentik early.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbaeb8266aac13639">
|
||||
<source>Buffer PolicyAccessVew requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="se20d0be2cece3841">
|
||||
<source>When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.</source>
|
||||
</trans-unit>
|
||||
@@ -9143,6 +9133,18 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s567c5a6e42cc5036">
|
||||
<source>Maximum page size for API requests.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sed1058bca1c065f7">
|
||||
<source>When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. </source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s9864bd5cd7bb4bd0">
|
||||
<source>Local connection</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s4a3f19ff6e510c37">
|
||||
<source>Requires Docker socket/Kubernetes Integration.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -394,6 +394,8 @@ When documenting errors, follow this structure:
|
||||
- **Diagrams**:
|
||||
- Use [Mermaid](https://mermaid.js.org/) for creating diagrams directly in markdown. Mermaid is our preferred tool for documentation diagrams as it allows for version control and easy updates.
|
||||
- For more complex diagrams, you can use tools like [Draw.io](https://draw.io). Ensure high contrast and text descriptions.
|
||||
- **authentik icons**:
|
||||
- For authentik icons in integration guides, reference assets from the user's own self-hosted instance to avoid external calls, for example: `https://authentik.company/static/dist/assets/icons/icon.svg`
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
56
website/integrations/media/jellyseerr/index.md
Normal file
56
website/integrations/media/jellyseerr/index.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Integrate with Jellyseerr
|
||||
sidebar_label: Jellyseerr
|
||||
support_level: community
|
||||
---
|
||||
|
||||
## What is Jellyseerr
|
||||
|
||||
> Jellyseerr is a free and open source application for managing requests in your media library. It integrates with media servers like Jellyfin, Plex, and Emby, and services such as Sonarr and Radarr.
|
||||
>
|
||||
> -- https://docs.seerr.dev/
|
||||
|
||||
## Preparation
|
||||
|
||||
- `jellyseerr.company` is the FQDN of the Jellyseerr installation.
|
||||
- `authentik.company` is the FQDN of the authentik installation.
|
||||
|
||||
## authentik configuration
|
||||
|
||||
To support the integration of Jellyseerr with authentik, you need to create an application/provider pair in authentik.
|
||||
|
||||
1. Log in to authentik as an administrator and open the authentik Admin interface.
|
||||
2. Navigate to **Applications** > **Applications** and click **Create with Provider** to create an application and provider pair. (Alternatively you can first create a provider separately, then create the application and connect it with the provider.)
|
||||
- **Application**: provide a descriptive name, a slug, an optional group for the type of application, the policy engine mode, and optional UI settings. Take note of the **Slug** value as it will be required later.
|
||||
- **Choose a Provider type**: OAuth2/OpenID
|
||||
- **Configure the Provider**: provide a name (or accept the auto-provided name), the authorization flow to use for this provider, and any required configurations.
|
||||
- Note the **Client ID** and **Client Secret** values because they will be required later.
|
||||
- Set a `Strict` redirect URI to `https://jellyseerr.company/login?provider=authentik&callback=true`.
|
||||
- Select any available signing key.
|
||||
- **Configure Bindings** _(optional):_ you can create a [binding](https://docs.goauthentik.io/docs/add-secure-apps/flows-stages/bindings/) (policy, group, or user) to manage the listing and access to applications on a user’s **My applications** page.
|
||||
|
||||
3. Click **Submit** to save the new application and provider.
|
||||
|
||||
## Jellyseerr configuration
|
||||
|
||||
:::info
|
||||
Jellyseer OAuth support is currently in preview, please make sure to use the `preview-OIDC` Docker tag.
|
||||
:::
|
||||
|
||||
1. Log in to Jellyseerr with an administrator account.
|
||||
2. Navigate to **Settings** > **Users**.
|
||||
3. Toggle on **Enable OpenID Connect Sign-In** and click on the cogwheel icon next to it.
|
||||
4. Click **Add OpenID Connect Provider** and configure the following settings:
|
||||
- **Provider Name**: `authentik`
|
||||
- **Logo**: `https://authentik.company/static/dist/assets/icons/icon.svg`
|
||||
- **Issuer URL**: `https://authentik.company/application/o/jellyseerr/`
|
||||
- **Client ID**: Client ID from authentik
|
||||
- **Client Secret**: Client Secret from authentik
|
||||
- Under **Advanced Settings**:
|
||||
- **Scopes**: `openid profile email groups`
|
||||
- **Allow New Users**: Enabled
|
||||
5. Click **Save Changes**.
|
||||
|
||||
## Configuration verification
|
||||
|
||||
To verify that authentik is correctly set up with Jellyseerr, log out of Jellyseerr and try logging back in using the authentik button. You should be redirected to authentik, and once authenticated you will be signed in to Jellyseerr.
|
||||
@@ -38,7 +38,7 @@ The steps to configure authentik include creating an application and provider pa
|
||||
- Note the **Client ID**, **Client Secret**, and **slug** values because they will be required later.
|
||||
- Set a `Strict` redirect URI to `https://beszel.company/api/oauth2-redirect`.
|
||||
- Select any available signing key.
|
||||
- **Configure Bindings** _(optional):_ you can create a [binding](https://docs.goauthentik.io/docs/add-secure-apps/flows-stages/bindings/) (policy, group, or user) to manage the listing and access to applications on a user’s \***\*My applications** \*_page_.\*
|
||||
- **Configure Bindings** _(optional):_ you can create a [binding](https://docs.goauthentik.io/docs/add-secure-apps/flows-stages/bindings/) (policy, group, or user) to manage the listing and access to applications on a user’s **My applications** page.
|
||||
|
||||
3. Click **Submit** to save the new application and provider.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user