mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 23:22:35 +02:00
Compare commits
9 Commits
flows/corr
...
packages/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a56a5b9c | ||
|
|
73afaed115 | ||
|
|
8b758402c0 | ||
|
|
050c9c31af | ||
|
|
921269f990 | ||
|
|
87732a413c | ||
|
|
8cfe83bd47 | ||
|
|
1df84d68dd | ||
|
|
7f8527461a |
4
.github/actions/test-results/action.yml
vendored
4
.github/actions/test-results/action.yml
vendored
@@ -12,11 +12,11 @@ runs:
|
||||
with:
|
||||
flags: ${{ inputs.flags }}
|
||||
use_oidc: true
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
- uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1
|
||||
with:
|
||||
flags: ${{ inputs.flags }}
|
||||
file: unittest.xml
|
||||
use_oidc: true
|
||||
report_type: test_results
|
||||
- name: PostgreSQL Logs
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
4
.github/workflows/release-tag.yml
vendored
4
.github/workflows/release-tag.yml
vendored
@@ -49,12 +49,8 @@ jobs:
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
|
||||
|
||||
def get_version_latest(self, _) -> str:
|
||||
"""Get latest version from cache"""
|
||||
if get_current_tenant().schema_name != get_public_schema_name():
|
||||
if get_current_tenant().schema_name == get_public_schema_name():
|
||||
return authentik_version()
|
||||
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
||||
if not version_in_cache: # pragma: no cover
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
"""Test file service layer"""
|
||||
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.manager import FileManager
|
||||
from authentik.admin.files.tests.utils import (
|
||||
FileTestFileBackendMixin,
|
||||
FileTestS3BackendMixin,
|
||||
s3_test_server_available,
|
||||
)
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin, FileTestS3BackendMixin
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
@@ -87,7 +81,6 @@ class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
self.assertEqual(result, "http://example.com/files/media/public/test.png")
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
|
||||
@CONFIG.patch("storage.media.s3.secure_urls", False)
|
||||
|
||||
@@ -15,9 +15,7 @@ class Pagination(pagination.PageNumberPagination):
|
||||
|
||||
def get_page_size(self, request):
|
||||
if self.page_size_query_param in request.query_params:
|
||||
page_size = super().get_page_size(request)
|
||||
if page_size is not None:
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
return request.tenant.pagination_default_page_size
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
|
||||
@@ -33,16 +33,6 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
|
||||
class PartialUserSerializer(ModelSerializer):
|
||||
"""Partial User Serializer, does not include child relations."""
|
||||
@@ -52,7 +42,16 @@ class PartialUserSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = PARTIAL_USER_SERIALIZER_MODEL_FIELDS + ["uid"]
|
||||
fields = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
"uid",
|
||||
]
|
||||
|
||||
|
||||
class RelatedGroupSerializer(ModelSerializer):
|
||||
@@ -247,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"),
|
||||
@@ -263,14 +258,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
base_qs = Group.objects.all().prefetch_related("roles")
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_users:
|
||||
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
|
||||
# time
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch(
|
||||
"users",
|
||||
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
|
||||
)
|
||||
)
|
||||
base_qs = base_qs.prefetch_related("users")
|
||||
else:
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -18,9 +18,10 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
||||
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
|
||||
|
||||
def get_role_for_user_id(user_id: int) -> Role:
|
||||
name = f"ak-migrated-role--user-{user_id}"
|
||||
name = f"ak-managed-role--user-{user_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
name=name,
|
||||
managed=name,
|
||||
)
|
||||
if created:
|
||||
role.users.add(user_id)
|
||||
@@ -31,10 +32,11 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
||||
if not role:
|
||||
# Every django group should already have a role, so this should never happen.
|
||||
# But let's be nice.
|
||||
name = f"ak-migrated-role--group-{group_id}"
|
||||
name = f"ak-managed-role--group-{group_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
group_id=group_id,
|
||||
name=name,
|
||||
managed=name,
|
||||
)
|
||||
if created:
|
||||
role.group_id = group_id
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
from typing import cast
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
|
||||
|
||||
class FlowActive(BaseAuthentication):
|
||||
"""Authenticate requests when a flow is currently active"""
|
||||
|
||||
def authenticate(self, request: Request):
|
||||
plan = cast(FlowPlan | None, request.session.get(SESSION_KEY_PLAN))
|
||||
if not plan:
|
||||
return None
|
||||
return (plan.context.get(PLAN_CONTEXT_PENDING_USER, AnonymousUser()), plan)
|
||||
@@ -249,7 +249,7 @@ class ChallengeStageView(StageView):
|
||||
"f(ch): invalid challenge response",
|
||||
errors=challenge_response.errors,
|
||||
)
|
||||
return HttpChallengeResponse(challenge_response, status=400)
|
||||
return HttpChallengeResponse(challenge_response)
|
||||
|
||||
|
||||
class AccessDeniedStage(ChallengeStageView):
|
||||
|
||||
@@ -48,9 +48,6 @@ class FlowTestCase(APITestCase):
|
||||
self.assertEqual(raw_response[key], expected)
|
||||
return raw_response
|
||||
|
||||
def get_flow_plan(self) -> FlowPlan | None:
|
||||
return self.client.session.get(SESSION_KEY_PLAN)
|
||||
|
||||
def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
|
||||
"""Wrapper around assertStageResponse that checks for a redirect"""
|
||||
return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
|
||||
|
||||
@@ -147,8 +147,6 @@ class FlowExecutorView(APIView):
|
||||
token.delete()
|
||||
if not isinstance(plan, FlowPlan):
|
||||
return None
|
||||
if existing_plan := self.request.session[SESSION_KEY_PLAN]:
|
||||
plan.context.update(existing_plan.context)
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
||||
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
||||
return plan
|
||||
@@ -258,11 +256,6 @@ class FlowExecutorView(APIView):
|
||||
serializers=challenge_types,
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
400: PolymorphicProxySerializer(
|
||||
component_name="ChallengeTypes",
|
||||
serializers=challenge_types,
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
},
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[
|
||||
@@ -310,11 +303,6 @@ class FlowExecutorView(APIView):
|
||||
serializers=challenge_types,
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
400: PolymorphicProxySerializer(
|
||||
component_name="ChallengeTypes",
|
||||
serializers=challenge_types,
|
||||
resource_type_field_name="component",
|
||||
),
|
||||
},
|
||||
request=PolymorphicProxySerializer(
|
||||
component_name="FlowChallengeResponse",
|
||||
|
||||
@@ -86,7 +86,7 @@ class OutpostConfig:
|
||||
class OutpostModel(Model):
|
||||
"""Base model for providers that need more objects than just themselves"""
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
"""Return a list of all required objects"""
|
||||
return [self]
|
||||
|
||||
@@ -332,35 +332,41 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
"""Create per-object and global permissions for outpost service-account"""
|
||||
# To ensure the user only has the correct permissions, we delete all of them and re-add
|
||||
# the ones the user needs
|
||||
try:
|
||||
with transaction.atomic():
|
||||
user.remove_all_perms_from_managed_role()
|
||||
for model_or_perm in self.get_required_objects():
|
||||
if isinstance(model_or_perm, models.Model):
|
||||
code_name = (
|
||||
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
|
||||
)
|
||||
with transaction.atomic():
|
||||
user.remove_all_perms_from_managed_role()
|
||||
for model_or_perm in self.get_required_objects():
|
||||
if isinstance(model_or_perm, models.Model):
|
||||
model_or_perm: models.Model
|
||||
code_name = (
|
||||
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
|
||||
)
|
||||
try:
|
||||
user.assign_perms_to_managed_role(code_name, model_or_perm)
|
||||
elif isinstance(model_or_perm, tuple):
|
||||
perm, obj = model_or_perm
|
||||
user.assign_perms_to_managed_role(perm, obj)
|
||||
else:
|
||||
user.assign_perms_to_managed_role(model_or_perm)
|
||||
except (Permission.DoesNotExist, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"permission doesn't exist",
|
||||
code_name=code_name,
|
||||
user=user,
|
||||
model=model_or_perm,
|
||||
)
|
||||
Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION,
|
||||
message=(
|
||||
"While setting the permissions for the service-account, a "
|
||||
"permission was not found: Check "
|
||||
"https://docs.goauthentik.io/troubleshooting/missing_permission"
|
||||
),
|
||||
).with_exception(exc).set_user(user).save()
|
||||
except (Permission.DoesNotExist, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"permission doesn't exist",
|
||||
code_name=code_name,
|
||||
user=user,
|
||||
model=model_or_perm,
|
||||
)
|
||||
Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION,
|
||||
message=(
|
||||
"While setting the permissions for the service-account, a "
|
||||
"permission was not found: Check "
|
||||
"https://docs.goauthentik.io/troubleshooting/missing_permission"
|
||||
),
|
||||
).with_exception(exc).set_user(user).save()
|
||||
else:
|
||||
app_label, perm = model_or_perm.split(".")
|
||||
permission = Permission.objects.filter(
|
||||
codename=perm,
|
||||
content_type__app_label=app_label,
|
||||
)
|
||||
if not permission.exists():
|
||||
LOGGER.warning("permission doesn't exist", perm=model_or_perm)
|
||||
continue
|
||||
user.assign_perms_to_managed_role(permission.first())
|
||||
LOGGER.debug(
|
||||
"Updated service account's permissions",
|
||||
obj_perms=user.get_all_obj_perms_on_managed_role(),
|
||||
@@ -425,7 +431,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
Token.objects.filter(identifier=self.token_identifier).delete()
|
||||
return self.token
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
"""Get an iterator of all objects the user needs read access to"""
|
||||
objects: list[models.Model | str] = [
|
||||
self,
|
||||
@@ -439,9 +445,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
if self.managed:
|
||||
for brand in Brand.objects.filter(web_certificate__isnull=False):
|
||||
objects.append(brand)
|
||||
objects.append(("view_certificatekeypair", brand.web_certificate))
|
||||
objects.append(("view_certificatekeypair_certificate", brand.web_certificate))
|
||||
objects.append(("view_certificatekeypair_key", brand.web_certificate))
|
||||
objects.append(brand.web_certificate)
|
||||
return objects
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -51,12 +51,10 @@ class OutpostTests(TestCase):
|
||||
permissions = outpost.user.get_all_obj_perms_on_managed_role().order_by(
|
||||
"content_type__model"
|
||||
)
|
||||
self.assertEqual(len(permissions), 5)
|
||||
self.assertEqual(len(permissions), 3)
|
||||
self.assertEqual(permissions[0].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[1].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[2].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[3].object_pk, str(outpost.pk))
|
||||
self.assertEqual(permissions[4].object_pk, str(provider.pk))
|
||||
self.assertEqual(permissions[1].object_pk, str(outpost.pk))
|
||||
self.assertEqual(permissions[2].object_pk, str(provider.pk))
|
||||
|
||||
# Remove provider from outpost, user should only have access to outpost
|
||||
outpost.providers.remove(provider)
|
||||
|
||||
@@ -93,13 +93,11 @@ class LDAPProvider(OutpostModel, BackchannelProvider):
|
||||
def __str__(self):
|
||||
return f"LDAP Provider {self.name}"
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
required = [self, "authentik_core.view_user", "authentik_core.view_group"]
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
required_models = [self, "authentik_core.view_user", "authentik_core.view_group"]
|
||||
if self.certificate is not None:
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
return required
|
||||
required_models.append(self.certificate)
|
||||
return required_models
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("LDAP Provider")
|
||||
|
||||
@@ -179,13 +179,11 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
def __str__(self):
|
||||
return f"Proxy Provider {self.name}"
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
required = [self]
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
required_models = [self]
|
||||
if self.certificate is not None:
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
return required
|
||||
required_models.append(self.certificate)
|
||||
return required_models
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Proxy Provider")
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"""proxy provider tests"""
|
||||
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.models import ClientTypes
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
|
||||
@@ -131,55 +127,3 @@ class ProxyProviderTests(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
|
||||
def test_sa_fetch(self):
|
||||
"""Test fetching the outpost config as the service account"""
|
||||
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
|
||||
provider = ProxyProvider.objects.create(name=generate_id())
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
outpost.providers.add(provider)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:proxyprovideroutpost-list"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
body = loads(res.content)
|
||||
self.assertEqual(body["pagination"]["count"], 1)
|
||||
|
||||
def test_sa_perms_cert(self):
|
||||
"""Test permissions to access a configured certificate"""
|
||||
cert = create_test_cert()
|
||||
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
|
||||
provider = ProxyProvider.objects.create(name=generate_id(), certificate=cert)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
outpost.providers.add(provider)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:proxyprovideroutpost-list"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
body = loads(res.content)
|
||||
self.assertEqual(body["pagination"]["count"], 1)
|
||||
cert_id = body["results"][0]["certificate"]
|
||||
self.assertEqual(cert_id, str(cert.pk))
|
||||
|
||||
res = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-view-certificate",
|
||||
kwargs={
|
||||
"pk": cert_id,
|
||||
},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
# res = self.client.get(
|
||||
# reverse(
|
||||
# "authentik_api:certificatekeypair-view-private-key",
|
||||
# kwargs={
|
||||
# "pk": cert_id,
|
||||
# },
|
||||
# ),
|
||||
# HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
# )
|
||||
# self.assertEqual(res.status_code, 200)
|
||||
|
||||
@@ -64,12 +64,10 @@ class RadiusProvider(OutpostModel, Provider):
|
||||
|
||||
return RadiusProviderSerializer
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
required = [self, "authentik_stages_mtls.pass_outpost_certificate"]
|
||||
if self.certificate is not None:
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
required.append(self.certificate)
|
||||
return required
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -9,7 +9,7 @@ from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, ChoiceField, IntegerField
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
@@ -21,11 +21,9 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
from authentik.flows.auth import FlowActive
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_duo.stage import PLAN_CONTEXT_DUO_ENROLL
|
||||
from authentik.stages.authenticator_duo.stage import SESSION_KEY_DUO_ENROLL
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -86,20 +84,14 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet):
|
||||
),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=True,
|
||||
authentication_classes=[FlowActive],
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
@action(methods=["POST"], detail=True, permission_classes=[IsAuthenticated])
|
||||
def enrollment_status(self, request: Request, pk: str) -> Response:
|
||||
"""Check enrollment status of user details in current session"""
|
||||
stage: AuthenticatorDuoStage = AuthenticatorDuoStage.objects.filter(pk=pk).first()
|
||||
if not stage:
|
||||
raise Http404
|
||||
client = stage.auth_client()
|
||||
plan: FlowPlan = request.auth
|
||||
enroll = plan.context.get(PLAN_CONTEXT_DUO_ENROLL)
|
||||
enroll = self.request.session.get(SESSION_KEY_DUO_ENROLL)
|
||||
if not enroll:
|
||||
return Response(status=400)
|
||||
status = client.enroll_status(enroll["user_id"], enroll["activation_code"])
|
||||
|
||||
@@ -14,7 +14,7 @@ from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import InvalidStageError
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
|
||||
PLAN_CONTEXT_DUO_ENROLL = "goauthentik.io/stages/authenticator_duo/enroll"
|
||||
SESSION_KEY_DUO_ENROLL = "authentik/stages/authenticator_duo/enroll"
|
||||
|
||||
|
||||
class AuthenticatorDuoChallenge(WithUserInfoChallenge):
|
||||
@@ -50,14 +50,14 @@ class AuthenticatorDuoStageView(ChallengeStageView):
|
||||
user=user,
|
||||
).from_http(self.request, user)
|
||||
raise InvalidStageError(str(exc)) from exc
|
||||
self.executor.plan.context[PLAN_CONTEXT_DUO_ENROLL] = enroll
|
||||
self.request.session[SESSION_KEY_DUO_ENROLL] = enroll
|
||||
return enroll
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
stage: AuthenticatorDuoStage = self.executor.current_stage
|
||||
if PLAN_CONTEXT_DUO_ENROLL not in self.executor.plan.context:
|
||||
if SESSION_KEY_DUO_ENROLL not in self.request.session:
|
||||
self.duo_enroll()
|
||||
enroll = self.executor.plan.context[PLAN_CONTEXT_DUO_ENROLL]
|
||||
enroll = self.request.session[SESSION_KEY_DUO_ENROLL]
|
||||
return AuthenticatorDuoChallenge(
|
||||
data={
|
||||
"activation_barcode": enroll["activation_barcode"],
|
||||
@@ -69,14 +69,14 @@ class AuthenticatorDuoStageView(ChallengeStageView):
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
# Duo Challenge has already been validated
|
||||
stage: AuthenticatorDuoStage = self.executor.current_stage
|
||||
enroll = self.executor.plan.context.get(PLAN_CONTEXT_DUO_ENROLL)
|
||||
enroll = self.request.session.get(SESSION_KEY_DUO_ENROLL)
|
||||
enroll_status = stage.auth_client().enroll_status(
|
||||
enroll["user_id"], enroll["activation_code"]
|
||||
)
|
||||
if enroll_status != "success":
|
||||
return self.executor.stage_invalid(f"Invalid enrollment status: {enroll_status}.")
|
||||
existing_device = DuoDevice.objects.filter(duo_user_id=enroll["user_id"]).first()
|
||||
self.executor.plan.context.pop(PLAN_CONTEXT_DUO_ENROLL)
|
||||
self.request.session.pop(SESSION_KEY_DUO_ENROLL)
|
||||
if not existing_device:
|
||||
DuoDevice.objects.create(
|
||||
name="Duo Authenticator",
|
||||
@@ -88,3 +88,6 @@ class AuthenticatorDuoStageView(ChallengeStageView):
|
||||
else:
|
||||
return self.executor.stage_invalid("Device with Credential ID already exists.")
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def cleanup(self):
|
||||
self.request.session.pop(SESSION_KEY_DUO_ENROLL, None)
|
||||
|
||||
@@ -11,6 +11,7 @@ from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_duo.stage import SESSION_KEY_DUO_ENROLL
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
|
||||
@@ -50,6 +51,42 @@ class AuthenticatorDuoStageTests(FlowTestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_api_enrollment(self):
|
||||
"""Test `enrollment_status`"""
|
||||
self.client.force_login(self.user)
|
||||
stage = AuthenticatorDuoStage.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_id(),
|
||||
api_hostname=generate_id(),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-enrollment-status",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_DUO_ENROLL] = {"user_id": "foo", "activation_code": "bar"}
|
||||
session.save()
|
||||
|
||||
with patch("duo_client.auth.Auth.enroll_status", MagicMock(return_value="foo")):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-enrollment-status",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content.decode(), '{"duo_response":"foo"}')
|
||||
|
||||
def test_api_import_manual_invalid_username(self):
|
||||
"""Test `import_device_manual`"""
|
||||
self.client.force_login(self.user)
|
||||
@@ -277,17 +314,6 @@ class AuthenticatorDuoStageTests(FlowTestCase):
|
||||
self.assertEqual(enroll_mock.call_count, 1)
|
||||
|
||||
with patch("duo_client.auth.Auth.enroll_status", MagicMock(return_value="success")):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-enrollment-status",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content, {"duo_response": "success"})
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), {}
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.http.request import QueryDict
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from rest_framework.fields import BooleanField, CharField, IntegerField
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import (
|
||||
@@ -26,7 +26,7 @@ from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
PLAN_CONTEXT_EMAIL_DEVICE = "goauthentik.io/stages/authenticator_email/email_device"
|
||||
SESSION_KEY_EMAIL_DEVICE = "authentik/stages/authenticator_email/email_device"
|
||||
PLAN_CONTEXT_EMAIL = "email"
|
||||
PLAN_CONTEXT_EMAIL_SENT = "email_sent"
|
||||
PLAN_CONTEXT_EMAIL_OVERRIDE = "email"
|
||||
@@ -47,7 +47,7 @@ class AuthenticatorEmailChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: EmailDevice
|
||||
|
||||
code = CharField(required=False)
|
||||
code = IntegerField(required=False)
|
||||
email = CharField(required=False)
|
||||
|
||||
component = CharField(default="ak-stage-authenticator-email")
|
||||
@@ -79,7 +79,7 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
if EmailDevice.objects.filter(Q(email=email), stage=stage.pk).exists():
|
||||
raise ValidationError(_("Invalid email"))
|
||||
|
||||
device: EmailDevice = self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
|
||||
try:
|
||||
message = TemplateEmailMessage(
|
||||
@@ -116,9 +116,9 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
self.logger.debug("got email from plan context")
|
||||
return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_EMAIL)
|
||||
# Check device for email
|
||||
if PLAN_CONTEXT_EMAIL_DEVICE in self.executor.plan.context:
|
||||
if SESSION_KEY_EMAIL_DEVICE in self.request.session:
|
||||
self.logger.debug("got email from device in session")
|
||||
device: EmailDevice = self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
if device.email == "":
|
||||
return None
|
||||
return device.email
|
||||
@@ -135,7 +135,7 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
|
||||
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
|
||||
response = super().get_response_instance(data)
|
||||
response.device = self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
response.device = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
return response
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
@@ -147,11 +147,11 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
return self.executor.stage_invalid(
|
||||
_("The user already has an email address registered for MFA.")
|
||||
)
|
||||
if PLAN_CONTEXT_EMAIL_DEVICE not in self.executor.plan.context:
|
||||
if SESSION_KEY_EMAIL_DEVICE not in self.request.session:
|
||||
device = EmailDevice(user=user, confirmed=False, stage=stage, name="Email Device")
|
||||
valid_secs: int = timedelta_from_string(stage.token_expiry).total_seconds()
|
||||
device.generate_token(valid_secs=valid_secs, commit=False)
|
||||
self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE] = device
|
||||
self.request.session[SESSION_KEY_EMAIL_DEVICE] = device
|
||||
if email := self._has_email():
|
||||
device.email = email
|
||||
try:
|
||||
@@ -165,16 +165,16 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop(
|
||||
PLAN_CONTEXT_EMAIL, None
|
||||
)
|
||||
self.executor.plan.context.pop(PLAN_CONTEXT_EMAIL_DEVICE, None)
|
||||
self.request.session.pop(SESSION_KEY_EMAIL_DEVICE, None)
|
||||
self.logger.warning("failed to send email to pre-set address", exc=exc)
|
||||
return self.get(request, *args, **kwargs)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
"""Email Token is validated by challenge"""
|
||||
device: EmailDevice = self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
if not device.confirmed:
|
||||
return self.challenge_invalid(response)
|
||||
device.save()
|
||||
del self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
del self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
return self.executor.stage_ok()
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
|
||||
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user
|
||||
from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.config import CONFIG
|
||||
@@ -21,7 +21,9 @@ from authentik.stages.authenticator_email.api import (
|
||||
EmailDeviceSerializer,
|
||||
)
|
||||
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
|
||||
from authentik.stages.authenticator_email.stage import PLAN_CONTEXT_EMAIL_DEVICE
|
||||
from authentik.stages.authenticator_email.stage import (
|
||||
SESSION_KEY_EMAIL_DEVICE,
|
||||
)
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
|
||||
@@ -31,7 +33,7 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.flow = create_test_flow()
|
||||
self.user = create_test_user()
|
||||
self.user = create_test_admin_user()
|
||||
self.user_noemail = create_test_user(email="")
|
||||
self.stage = AuthenticatorEmailStage.objects.create(
|
||||
name="email-authenticator",
|
||||
@@ -211,26 +213,20 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email"},
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
response_errors={"non_field_errors": [{"code": "invalid", "string": "email required"}]},
|
||||
)
|
||||
self.assertIn("email required", str(response.content))
|
||||
|
||||
# Test invalid code
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email", "code": "000000"},
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
response_errors={
|
||||
"non_field_errors": [{"code": "invalid", "string": "Code does not match"}]
|
||||
},
|
||||
)
|
||||
self.assertIn("Code does not match", str(response.content))
|
||||
|
||||
# Test valid code
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
device = self.device
|
||||
token = device.token
|
||||
response = self.client.post(
|
||||
@@ -289,7 +285,8 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
device = self.get_flow_plan().context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
self.assertIn(SESSION_KEY_EMAIL_DEVICE, self.client.session)
|
||||
device = self.client.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
self.assertIsInstance(device, EmailDevice)
|
||||
self.assertFalse(device.confirmed)
|
||||
self.assertEqual(device.user, self.user)
|
||||
@@ -297,6 +294,8 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
# Test device confirmation and cleanup
|
||||
device.confirmed = True
|
||||
device.email = "new_test@authentik.local" # Use a different email
|
||||
self.client.session[SESSION_KEY_EMAIL_DEVICE] = device
|
||||
self.client.session.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email", "code": device.token},
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from rest_framework.fields import BooleanField, CharField, IntegerField
|
||||
|
||||
from authentik.flows.challenge import (
|
||||
Challenge,
|
||||
@@ -20,7 +20,7 @@ from authentik.stages.authenticator_sms.models import (
|
||||
)
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
PLAN_CONTEXT_SMS_DEVICE = "goauthentik.io/stages/authenticator_sms/sms_device"
|
||||
SESSION_KEY_SMS_DEVICE = "authentik/stages/authenticator_sms/sms_device"
|
||||
PLAN_CONTEXT_PHONE = "phone"
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class AuthenticatorSMSChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: SMSDevice
|
||||
|
||||
code = CharField(required=False)
|
||||
code = IntegerField(required=False)
|
||||
phone_number = CharField(required=False)
|
||||
|
||||
component = CharField(default="ak-stage-authenticator-sms")
|
||||
@@ -70,7 +70,7 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
if SMSDevice.objects.filter(query, stage=stage.pk).exists():
|
||||
raise ValidationError(_("Invalid phone number"))
|
||||
# No code yet, but we have a phone number, so send a verification message
|
||||
device: SMSDevice = self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
stage.send(self.request, device.token, device)
|
||||
|
||||
def _has_phone_number(self) -> str | None:
|
||||
@@ -78,9 +78,9 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
if PLAN_CONTEXT_PHONE in context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
self.logger.debug("got phone number from plan context")
|
||||
return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_PHONE)
|
||||
if PLAN_CONTEXT_SMS_DEVICE in self.executor.plan.context:
|
||||
if SESSION_KEY_SMS_DEVICE in self.request.session:
|
||||
self.logger.debug("got phone number from device in session")
|
||||
device: SMSDevice = self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
if device.phone_number == "":
|
||||
return None
|
||||
return device.phone_number
|
||||
@@ -95,7 +95,7 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
|
||||
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
|
||||
response = super().get_response_instance(data)
|
||||
response.device = self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
response.device = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
return response
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
@@ -103,10 +103,10 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
|
||||
stage: AuthenticatorSMSStage = self.executor.current_stage
|
||||
|
||||
if PLAN_CONTEXT_SMS_DEVICE not in self.executor.plan.context:
|
||||
if SESSION_KEY_SMS_DEVICE not in self.request.session:
|
||||
device = SMSDevice(user=user, confirmed=False, stage=stage, name="SMS Device")
|
||||
device.generate_token(commit=False)
|
||||
self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE] = device
|
||||
self.request.session[SESSION_KEY_SMS_DEVICE] = device
|
||||
if phone_number := self._has_phone_number():
|
||||
device.phone_number = phone_number
|
||||
try:
|
||||
@@ -120,14 +120,14 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop(
|
||||
PLAN_CONTEXT_PHONE, None
|
||||
)
|
||||
self.executor.plan.context.pop(PLAN_CONTEXT_SMS_DEVICE, None)
|
||||
self.request.session.pop(SESSION_KEY_SMS_DEVICE, None)
|
||||
self.logger.warning("failed to send SMS message to pre-set number", exc=exc)
|
||||
return self.get(request, *args, **kwargs)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
"""SMS Token is validated by challenge"""
|
||||
device: SMSDevice = self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
if not device.confirmed:
|
||||
return self.challenge_invalid(response)
|
||||
stage: AuthenticatorSMSStage = self.executor.current_stage
|
||||
@@ -135,5 +135,5 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
self.logger.debug("Hashing number on device")
|
||||
device.set_hashed_number()
|
||||
device.save()
|
||||
del self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
del self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
return self.executor.stage_ok()
|
||||
|
||||
@@ -18,7 +18,7 @@ from authentik.stages.authenticator_sms.models import (
|
||||
SMSProviders,
|
||||
hash_phone_number,
|
||||
)
|
||||
from authentik.stages.authenticator_sms.stage import PLAN_CONTEXT_PHONE, PLAN_CONTEXT_SMS_DEVICE
|
||||
from authentik.stages.authenticator_sms.stage import PLAN_CONTEXT_PHONE, SESSION_KEY_SMS_DEVICE
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class AuthenticatorSMSStageTests(FlowTestCase):
|
||||
self.assertEqual(mocker.call_count, 1)
|
||||
self.assertEqual(mocker.request_history[0].method, "POST")
|
||||
request_body = dict(parse_qsl(mocker.request_history[0].body))
|
||||
device: SMSDevice = self.get_flow_plan().context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
device: SMSDevice = self.client.session[SESSION_KEY_SMS_DEVICE]
|
||||
self.assertEqual(
|
||||
request_body,
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ from urllib.parse import quote
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from authentik.flows.challenge import (
|
||||
@@ -32,10 +32,10 @@ class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: TOTPDevice
|
||||
|
||||
code = CharField()
|
||||
code = IntegerField()
|
||||
component = CharField(default="ak-stage-authenticator-totp")
|
||||
|
||||
def validate_code(self, code: str) -> str:
|
||||
def validate_code(self, code: int) -> int:
|
||||
"""Validate totp code"""
|
||||
if not self.device:
|
||||
raise ValidationError(_("Code does not match"))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""authentik consent stage"""
|
||||
|
||||
from hmac import compare_digest
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
@@ -24,7 +23,7 @@ PLAN_CONTEXT_CONSENT = "consent"
|
||||
PLAN_CONTEXT_CONSENT_HEADER = "consent_header"
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions"
|
||||
PLAN_CONTEXT_CONSENT_EXTRA_PERMISSIONS = "consent_additional_permissions"
|
||||
PLAN_CONTEXT_CONSENT_TOKEN = "goauthentik.io/stages/consent/token" # nosec
|
||||
SESSION_KEY_CONSENT_TOKEN = "authentik/stages/consent/token" # nosec
|
||||
|
||||
|
||||
class ConsentPermissionSerializer(PassiveSerializer):
|
||||
@@ -51,9 +50,7 @@ class ConsentChallengeResponse(ChallengeResponse):
|
||||
token = CharField(required=True)
|
||||
|
||||
def validate_token(self, token: str):
|
||||
if not compare_digest(
|
||||
token, self.stage.executor.plan.context.get(PLAN_CONTEXT_CONSENT_TOKEN, "")
|
||||
):
|
||||
if token != self.stage.executor.request.session[SESSION_KEY_CONSENT_TOKEN]:
|
||||
raise ValidationError(_("Invalid consent token, re-showing prompt"))
|
||||
return token
|
||||
|
||||
@@ -65,7 +62,7 @@ class ConsentStageView(ChallengeStageView):
|
||||
|
||||
def get_challenge(self) -> Challenge:
|
||||
token = str(uuid4())
|
||||
self.executor.plan.context[PLAN_CONTEXT_CONSENT_TOKEN] = token
|
||||
self.request.session[SESSION_KEY_CONSENT_TOKEN] = token
|
||||
data = {
|
||||
"permissions": self.executor.plan.context.get(PLAN_CONTEXT_CONSENT_PERMISSIONS, []),
|
||||
"additional_permissions": self.executor.plan.context.get(
|
||||
|
||||
@@ -19,7 +19,7 @@ from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConse
|
||||
from authentik.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_HEADER,
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
||||
PLAN_CONTEXT_CONSENT_TOKEN,
|
||||
SESSION_KEY_CONSENT_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
@@ -83,10 +83,11 @@ class TestConsentStage(FlowTestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
session = self.client.session
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
{
|
||||
"token": self.get_flow_plan().context[PLAN_CONTEXT_CONSENT_TOKEN],
|
||||
"token": session[SESSION_KEY_CONSENT_TOKEN],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -121,7 +122,7 @@ class TestConsentStage(FlowTestCase):
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
{
|
||||
"token": self.get_flow_plan().context[PLAN_CONTEXT_CONSENT_TOKEN],
|
||||
"token": session[SESSION_KEY_CONSENT_TOKEN],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -19,7 +19,7 @@ from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN, FlowExecutorView
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TOKEN
|
||||
from authentik.stages.consent.stage import SESSION_KEY_CONSENT_TOKEN
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, EmailStageView
|
||||
|
||||
@@ -174,7 +174,7 @@ class TestEmailStage(FlowTestCase):
|
||||
kwargs={"flow_slug": self.flow.slug},
|
||||
),
|
||||
data={
|
||||
"token": self.get_flow_plan().context[PLAN_CONTEXT_CONSENT_TOKEN],
|
||||
"token": self.client.session[SESSION_KEY_CONSENT_TOKEN],
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
@@ -245,10 +245,7 @@ class WorkerStatusMiddleware(Middleware):
|
||||
WorkerStatusMiddleware.keep(status)
|
||||
except DB_ERRORS: # pragma: no cover
|
||||
sleep(10)
|
||||
try:
|
||||
connections.close_all()
|
||||
except DB_ERRORS:
|
||||
pass
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def keep(status: WorkerStatus):
|
||||
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./media:/data/media
|
||||
- ./custom-templates:/templates
|
||||
worker:
|
||||
command: worker
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
user: root
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data:/data
|
||||
- ./media:/data/media
|
||||
- ./certs:/certs
|
||||
- ./custom-templates:/templates
|
||||
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.2026020.3
|
||||
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.2026020.3 h1:CKtPyAQToPT2yF5odTTc+IfPLhYeVX9FbLMeVnFgZps=
|
||||
goauthentik.io/api/v3 v3.2026020.3/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,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -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:10dadf1df1337e8eb4218acef6a3027abebf0a155a95d792af736f85ab0e9588
|
||||
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.1100.1",
|
||||
"aws-cdk": "^2.1034.0",
|
||||
"cross-env": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -25,9 +25,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1100.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1100.1.tgz",
|
||||
"integrity": "sha512-q2poFrQh90TK6eqeI0zznA8r1JkDI63WVOSqC7gFGo6qjQjAnvFk/utxHoNRgAC0RL0CLd19uCcHh3jfX9NiSg==",
|
||||
"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.1100.1",
|
||||
"aws-cdk": "^2.1034.0",
|
||||
"cross-env": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-17 00:10+0000\n"
|
||||
"POT-Creation-Date: 2025-12-12 15:51+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -558,30 +558,6 @@ msgid ""
|
||||
"encryption."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Key algorithm type detected from the certificate's public key"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Certificate expiry date"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Certificate subject as RFC4514 string"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "SHA256 fingerprint of the certificate"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "SHA1 fingerprint of the certificate"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Key ID generated from private key"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Certificate-Key Pair"
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 = {}
|
||||
@@ -17,7 +17,7 @@ COPY web .
|
||||
RUN npm run build-proxy
|
||||
|
||||
# Stage 2: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -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:10dadf1df1337e8eb4218acef6a3027abebf0a155a95d792af736f85ab0e9588
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:2f19fc114923ec0842329bf638cb155e597c4be9c8119a3db038ffc3fede9228
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
|
||||
@@ -6,6 +6,7 @@ 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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -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:10dadf1df1337e8eb4218acef6a3027abebf0a155a95d792af736f85ab0e9588
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:2f19fc114923ec0842329bf638cb155e597c4be9c8119a3db038ffc3fede9228
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
|
||||
@@ -33581,8 +33581,7 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-email
|
||||
code:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: integer
|
||||
email:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -33829,8 +33828,7 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-sms
|
||||
code:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: integer
|
||||
phone_number:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -34104,8 +34102,7 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-totp
|
||||
code:
|
||||
type: string
|
||||
minLength: 1
|
||||
type: integer
|
||||
required:
|
||||
- code
|
||||
AuthenticatorTOTPStage:
|
||||
|
||||
6
scripts/generate_docker_compose.py
Executable file → Normal file
6
scripts/generate_docker_compose.py
Executable file → Normal file
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from yaml import safe_dump
|
||||
|
||||
from authentik import authentik_version
|
||||
@@ -44,7 +42,7 @@ base = {
|
||||
"image": authentik_image,
|
||||
"ports": ["${COMPOSE_PORT_HTTP:-9000}:9000", "${COMPOSE_PORT_HTTPS:-9443}:9443"],
|
||||
"restart": "unless-stopped",
|
||||
"volumes": ["./data:/data", "./custom-templates:/templates"],
|
||||
"volumes": ["./media:/data/media", "./custom-templates:/templates"],
|
||||
},
|
||||
"worker": {
|
||||
"command": "worker",
|
||||
@@ -64,7 +62,7 @@ base = {
|
||||
"user": "root",
|
||||
"volumes": [
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"./data:/data",
|
||||
"./media:/data/media",
|
||||
"./certs:/certs",
|
||||
"./custom-templates:/templates",
|
||||
],
|
||||
|
||||
@@ -249,60 +249,36 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
Raises a clear test failure if the element isn't found, the text doesn't appear
|
||||
within `timeout` seconds, or the text is not valid JSON.
|
||||
"""
|
||||
use_body = context is None
|
||||
wait_timeout = timeout or self.wait_timeout
|
||||
|
||||
def get_context() -> WebElement:
|
||||
"""Get or refresh the context element."""
|
||||
if use_body:
|
||||
return self.driver.find_element(By.TAG_NAME, "body")
|
||||
return context
|
||||
|
||||
def get_text_safely() -> str:
|
||||
"""Get element text, re-finding element if stale."""
|
||||
for _ in range(5):
|
||||
try:
|
||||
return get_context().text.strip()
|
||||
except StaleElementReferenceException:
|
||||
sleep(0.5)
|
||||
return get_context().text.strip()
|
||||
|
||||
def get_inner_html_safely() -> str:
|
||||
"""Get innerHTML, re-finding element if stale."""
|
||||
for _ in range(5):
|
||||
try:
|
||||
return get_context().get_attribute("innerHTML") or ""
|
||||
except StaleElementReferenceException:
|
||||
sleep(0.5)
|
||||
return get_context().get_attribute("innerHTML") or ""
|
||||
|
||||
try:
|
||||
get_context()
|
||||
if context is None:
|
||||
context = self.driver.find_element(By.TAG_NAME, "body")
|
||||
except NoSuchElementException:
|
||||
self.fail(
|
||||
f"No element found (defaulted to <body>). Current URL: {self.driver.current_url}"
|
||||
)
|
||||
|
||||
wait = WebDriverWait(self.driver, wait_timeout)
|
||||
wait_timeout = timeout or self.wait_timeout
|
||||
wait = WebDriverWait(context, wait_timeout)
|
||||
|
||||
try:
|
||||
wait.until(lambda d: len(get_text_safely()) != 0)
|
||||
wait.until(lambda d: len(d.text.strip()) != 0)
|
||||
except TimeoutException:
|
||||
snippet = get_text_safely()[:500].replace("\n", " ")
|
||||
snippet = context.text.strip()[:500].replace("\n", " ")
|
||||
self.fail(
|
||||
f"Timed out waiting for element text to appear at {self.driver.current_url}. "
|
||||
f"Current content: {snippet or '<empty>'}"
|
||||
)
|
||||
|
||||
body_text = get_text_safely()
|
||||
inner_html = get_inner_html_safely()
|
||||
body_text = context.text.strip()
|
||||
inner_html = context.get_attribute("innerHTML") or ""
|
||||
|
||||
if "redirecting" in inner_html.lower():
|
||||
try:
|
||||
wait.until(lambda d: "redirecting" not in get_inner_html_safely().lower())
|
||||
wait.until(lambda d: "redirecting" not in d.get_attribute("innerHTML").lower())
|
||||
except TimeoutException:
|
||||
snippet = get_text_safely()[:500].replace("\n", " ")
|
||||
inner_html = get_inner_html_safely()
|
||||
snippet = context.text.strip()[:500].replace("\n", " ")
|
||||
inner_html = context.get_attribute("innerHTML") or ""
|
||||
|
||||
self.fail(
|
||||
f"Timed out waiting for redirect to finish at {self.driver.current_url}. "
|
||||
@@ -310,8 +286,8 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
f"{inner_html or '<empty>'}"
|
||||
)
|
||||
|
||||
inner_html = get_inner_html_safely()
|
||||
body_text = get_text_safely()
|
||||
inner_html = context.get_attribute("innerHTML") or ""
|
||||
body_text = context.text.strip()
|
||||
|
||||
snippet = body_text[:500].replace("\n", " ")
|
||||
|
||||
|
||||
27
uv.lock
generated
27
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"
|
||||
@@ -189,6 +201,7 @@ 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"
|
||||
|
||||
1447
web/package-lock.json
generated
1447
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -93,15 +93,15 @@
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@formatjs/intl-listformat": "^7.7.13",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@goauthentik/api": "^2025.10.0-rc1-1760614339",
|
||||
"@goauthentik/core": "^1.0.0",
|
||||
"@goauthentik/esbuild-plugin-live-reload": "^1.4.0",
|
||||
"@goauthentik/eslint-config": "^1.2.0",
|
||||
"@goauthentik/prettier-config": "^3.3.1",
|
||||
"@goauthentik/esbuild-plugin-live-reload": "^1.3.1",
|
||||
"@goauthentik/eslint-config": "^1.1.1",
|
||||
"@goauthentik/prettier-config": "^3.2.1",
|
||||
"@goauthentik/tsconfig": "^1.0.5",
|
||||
"@hcaptcha/types": "^1.1.0",
|
||||
"@lit/context": "^1.1.6",
|
||||
@@ -117,20 +117,20 @@
|
||||
"@patternfly/elements": "^4.2.0",
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sentry/browser": "^10.31.0",
|
||||
"@storybook/addon-docs": "^10.1.10",
|
||||
"@storybook/addon-links": "^10.1.10",
|
||||
"@storybook/web-components": "^10.1.10",
|
||||
"@storybook/web-components-vite": "^10.1.10",
|
||||
"@sentry/browser": "^10.29.0",
|
||||
"@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",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.50.0",
|
||||
"@vitest/browser": "^4.0.16",
|
||||
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
||||
"@typescript-eslint/parser": "^8.47.0",
|
||||
"@vitest/browser": "^4.0.15",
|
||||
"@vitest/browser-playwright": "^4.0.15",
|
||||
"@webcomponents/webcomponentsjs": "^2.8.0",
|
||||
"base64-js": "^1.5.1",
|
||||
@@ -143,15 +143,15 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dompurify": "^3.3.1",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "^9.39.2",
|
||||
"esbuild": "^0.27.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-lit": "^2.1.1",
|
||||
"eslint-plugin-wc": "^3.0.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"globals": "^16.5.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"hastscript": "^9.0.1",
|
||||
"knip": "^5.75.1",
|
||||
"knip": "^5.73.1",
|
||||
"lex": "^2025.11.0",
|
||||
"lit": "^3.3.1",
|
||||
"lit-analyzer": "^2.0.3",
|
||||
@@ -182,9 +182,9 @@
|
||||
"turnstile-types": "^1.2.3",
|
||||
"type-fest": "^5.3.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.0",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^4.0.15",
|
||||
"webcomponent-qr-code": "^1.3.0",
|
||||
"wireit": "^0.14.12",
|
||||
@@ -194,10 +194,10 @@
|
||||
"@esbuild/darwin-arm64": "^0.27.0",
|
||||
"@esbuild/linux-arm64": "^0.27.0",
|
||||
"@esbuild/linux-x64": "^0.27.0",
|
||||
"@rollup/rollup-darwin-arm64": "^4.53.5",
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.53.5",
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.53.5",
|
||||
"chromedriver": "^143.0.2"
|
||||
"@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.1"
|
||||
},
|
||||
"wireit": {
|
||||
"build": {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@goauthentik/tsconfig": "^1.0.5",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"semver": "^7.7.3",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@rollup/plugin-swc": "^0.4.0",
|
||||
"@swc/cli": "^0.7.9",
|
||||
"@swc/core": "^1.15.6",
|
||||
"@swc/core": "^1.15.3",
|
||||
"base64-js": "^1.5.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"formdata-polyfill": "^2025.11.0",
|
||||
"globby": "16.0.0",
|
||||
"jquery": "^3.7.1",
|
||||
"rollup": "^4.53.5",
|
||||
"rollup": "^4.53.3",
|
||||
"weakmap-polyfill": "^2.0.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -77,11 +77,7 @@ export class AgentConnectorSetup extends AKElement {
|
||||
<p>${msg("Afterwards, select the enrollment token you want to use:")}</p>
|
||||
</div>
|
||||
<div class="pf-l-grid__item pf-m-12-col">
|
||||
<p>
|
||||
${msg(
|
||||
"Next, download the configuration to deploy the authentik Agent via MDM",
|
||||
)}
|
||||
</p>
|
||||
<p>${msg("Then download the configuration to deploy the authentik Agent")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-grid__item pf-m-6-col pf-l-grid">
|
||||
|
||||
@@ -77,13 +77,10 @@ export class EnrollmentTokenForm extends WithBrandConfig(ModelForm<EnrollmentTok
|
||||
value=${ifDefined(this.instance?.name)}
|
||||
required
|
||||
></ak-text-input>
|
||||
<ak-form-element-horizontal label=${msg("Device Access Group")} name="deviceGroup">
|
||||
<ak-form-element-horizontal label=${msg("Device Group")} name="deviceGroup">
|
||||
<ak-endpoints-device-group-search
|
||||
.group=${this.instance?.deviceGroup}
|
||||
></ak-endpoints-device-group-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Select a device access group to be added to upon enrollment.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="expiring">
|
||||
<label class="pf-c-switch">
|
||||
|
||||
@@ -68,7 +68,7 @@ export class DeviceViewPage extends AKElement {
|
||||
? msg(str`Device ${this.device?.name}`)
|
||||
: msg("Loading device..."),
|
||||
description: this.device?.facts.data.os
|
||||
? `${this.device?.facts.data.os?.name} ${this.device?.facts.data.os?.version}`
|
||||
? this.device?.facts.data.os?.name + " " + this.device?.facts.data.os?.version
|
||||
: undefined,
|
||||
icon: "fa fa-laptop",
|
||||
});
|
||||
@@ -110,7 +110,7 @@ export class DeviceViewPage extends AKElement {
|
||||
?good=${this.device.facts.data.network?.firewallEnabled}
|
||||
></ak-status-label>`,
|
||||
],
|
||||
[msg("Device access group"), this.device.accessGroupObj?.name ?? "-"],
|
||||
[msg("Group"), this.device.accessGroupObj?.name ?? "-"],
|
||||
[
|
||||
msg("Actions"),
|
||||
html`<ak-forms-modal>
|
||||
@@ -162,13 +162,13 @@ export class DeviceViewPage extends AKElement {
|
||||
></ak-status-label>`,
|
||||
],
|
||||
[
|
||||
msg("Primary disk size"),
|
||||
msg("Disk size"),
|
||||
rootDisk?.capacityTotalBytes
|
||||
? getSize(rootDisk.capacityTotalBytes)
|
||||
: "-",
|
||||
],
|
||||
[
|
||||
msg("Primary disk usage"),
|
||||
msg("Disk usage"),
|
||||
rootDisk?.capacityTotalBytes && rootDisk.capacityUsedBytes
|
||||
? html`<progress
|
||||
value="${rootDisk.capacityUsedBytes}"
|
||||
|
||||
@@ -91,20 +91,6 @@ export class DataExportListPage extends TablePage<DataExport> {
|
||||
</div>
|
||||
</dl>`;
|
||||
}
|
||||
|
||||
protected renderEmpty(_inner?: TemplateResult): TemplateResult {
|
||||
return super.renderEmpty(
|
||||
html`<ak-empty-state icon=${this.pageIcon}
|
||||
><span
|
||||
>${msg(
|
||||
html`To create a data export, navigate to
|
||||
<a href="#/identity/users">Directory > Users</a> or to
|
||||
<a href="#/events/log">Events > Logs</a>.`,
|
||||
)}</span
|
||||
>
|
||||
</ak-empty-state>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -16,33 +16,18 @@ import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
// Same regex is used in the backend as well
|
||||
const VALID_FILE_NAME_PATTERN = /^[a-zA-Z0-9._/-]+$/;
|
||||
|
||||
// Note: browsers compile `pattern` using the new `v` RegExp flag (Unicode sets). Under `/v`,
|
||||
// both `/` and `-` must be escaped inside character classes.
|
||||
const VALID_FILE_NAME_PATTERN_STRING = "^[a-zA-Z0-9._\\/\\-]+$";
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/source
|
||||
// This is perfect for the "pattern" attribute
|
||||
const VALID_FILE_NAME_PATTERN_STRING = VALID_FILE_NAME_PATTERN.source;
|
||||
|
||||
function assertValidFileName(fileName: string): void {
|
||||
if (!VALID_FILE_NAME_PATTERN.test(fileName)) {
|
||||
throw new Error(
|
||||
msg(
|
||||
"Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes",
|
||||
),
|
||||
msg("Filename can only contain letters, numbers, dots, hyphens, and underscores"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getFileExtension(fileName: string): string {
|
||||
const lastDot = fileName.lastIndexOf(".");
|
||||
if (lastDot <= 0) return "";
|
||||
return fileName.slice(lastDot);
|
||||
}
|
||||
|
||||
function hasBasenameExtension(fileName: string): boolean {
|
||||
const baseName = fileName.split("/").pop() ?? fileName;
|
||||
const lastDot = baseName.lastIndexOf(".");
|
||||
return lastDot > 0;
|
||||
}
|
||||
|
||||
@customElement("ak-file-upload-form")
|
||||
export class FileUploadForm extends Form<Record<string, unknown>> {
|
||||
@property({ type: String, useDefault: true })
|
||||
@@ -72,36 +57,36 @@ export class FileUploadForm extends Form<Record<string, unknown>> {
|
||||
throw new PreventFormSubmit("Selected file not provided", this);
|
||||
}
|
||||
|
||||
assertValidFileName(this.selectedFile.name);
|
||||
|
||||
const api = new AdminApi(DEFAULT_CONFIG);
|
||||
const customName = typeof data.name === "string" ? data.name.trim() : "";
|
||||
const customName = typeof data.fileName === "string" ? data.fileName.trim() : "";
|
||||
|
||||
// If custom name provided, validate and append original extension
|
||||
// Only validate the original filename if no custom name is provided
|
||||
let finalName = this.selectedFile.name;
|
||||
if (customName) {
|
||||
assertValidFileName(customName);
|
||||
const ext = getFileExtension(this.selectedFile.name);
|
||||
finalName =
|
||||
ext && !hasBasenameExtension(customName) ? `${customName}${ext}` : customName;
|
||||
} else {
|
||||
assertValidFileName(this.selectedFile.name);
|
||||
const ext = this.selectedFile.name.substring(this.selectedFile.name.lastIndexOf("."));
|
||||
finalName = customName + ext;
|
||||
}
|
||||
|
||||
assertValidFileName(finalName);
|
||||
return api
|
||||
.adminFileCreate({
|
||||
file: this.selectedFile,
|
||||
name: finalName,
|
||||
usage: this.usage,
|
||||
})
|
||||
.then(() => {
|
||||
showMessage({
|
||||
level: MessageLevel.success,
|
||||
message: msg("File uploaded successfully"),
|
||||
});
|
||||
|
||||
await api.adminFileCreate({
|
||||
file: this.selectedFile,
|
||||
name: finalName,
|
||||
usage: this.usage,
|
||||
});
|
||||
|
||||
showMessage({
|
||||
level: MessageLevel.success,
|
||||
message: msg("File uploaded successfully"),
|
||||
});
|
||||
|
||||
this.reset();
|
||||
this.clearFileInput();
|
||||
this.reset();
|
||||
})
|
||||
.finally(() => {
|
||||
this.clearFileInput();
|
||||
});
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
@@ -116,7 +101,7 @@ export class FileUploadForm extends Form<Record<string, unknown>> {
|
||||
@change=${this.#fileChangeListener}
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("File Name")} name="name">
|
||||
<ak-form-element-horizontal label=${msg("File Name")} name="fileName">
|
||||
<input
|
||||
type="text"
|
||||
class="pf-c-form-control"
|
||||
|
||||
@@ -71,45 +71,40 @@ export class RoleObjectPermissionForm extends ModelForm<RoleAssignData, number>
|
||||
if (!this.modelPermissions) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<span
|
||||
>${msg(
|
||||
"Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.",
|
||||
)}</span
|
||||
>
|
||||
<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${msg("Role")} name="role">
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<Role[]> => {
|
||||
const args: RbacRolesListRequest = {
|
||||
ordering: "name",
|
||||
};
|
||||
if (query !== undefined) {
|
||||
args.search = query;
|
||||
}
|
||||
const roles = await new RbacApi(DEFAULT_CONFIG).rbacRolesList(args);
|
||||
return roles.results;
|
||||
}}
|
||||
.renderElement=${(role: Role): string => {
|
||||
return role.name;
|
||||
}}
|
||||
.value=${(role: Role | undefined): string | undefined => {
|
||||
return role?.pk;
|
||||
}}
|
||||
>
|
||||
</ak-search-select>
|
||||
</ak-form-element-horizontal>
|
||||
${this.modelPermissions?.results
|
||||
.filter((perm) => {
|
||||
const [_app, model] = this.model?.split(".") || "";
|
||||
return perm.codename !== `add_${model}`;
|
||||
})
|
||||
.map((perm) => {
|
||||
return html`<ak-switch-input
|
||||
name="permissions.${perm.codename}"
|
||||
label=${perm.name}
|
||||
></ak-switch-input>`;
|
||||
})}
|
||||
</form>`;
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${msg("Role")} name="role">
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<Role[]> => {
|
||||
const args: RbacRolesListRequest = {
|
||||
ordering: "name",
|
||||
};
|
||||
if (query !== undefined) {
|
||||
args.search = query;
|
||||
}
|
||||
const roles = await new RbacApi(DEFAULT_CONFIG).rbacRolesList(args);
|
||||
return roles.results;
|
||||
}}
|
||||
.renderElement=${(role: Role): string => {
|
||||
return role.name;
|
||||
}}
|
||||
.value=${(role: Role | undefined): string | undefined => {
|
||||
return role?.pk;
|
||||
}}
|
||||
>
|
||||
</ak-search-select>
|
||||
</ak-form-element-horizontal>
|
||||
${this.modelPermissions?.results
|
||||
.filter((perm) => {
|
||||
const [_app, model] = this.model?.split(".") || "";
|
||||
return perm.codename !== `add_${model}`;
|
||||
})
|
||||
.map((perm) => {
|
||||
return html`<ak-switch-input
|
||||
name="permissions.${perm.codename}"
|
||||
label=${perm.name}
|
||||
></ak-switch-input>`;
|
||||
})}
|
||||
</form>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
|
||||
renderObjectCreate(): TemplateResult {
|
||||
return html`<ak-forms-modal>
|
||||
<span slot="submit">${msg("Assign")}</span>
|
||||
<span slot="header">${msg("Assign object permissions to role")}</span>
|
||||
<span slot="header">${msg("Assign permission to role")}</span>
|
||||
<ak-rbac-role-object-permission-form
|
||||
model=${ifDefined(this.model)}
|
||||
objectPk=${ifDefined(this.objectPk)}
|
||||
@@ -95,7 +95,7 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
|
||||
>
|
||||
</ak-rbac-role-object-permission-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">
|
||||
${msg("Assign Object Permission")}
|
||||
${msg("Assign role permissions")}
|
||||
</button>
|
||||
</ak-forms-modal>`;
|
||||
}
|
||||
@@ -135,9 +135,9 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
|
||||
const assignedToModel = item.modelPermissions.some(
|
||||
(uperm) => uperm.codename === perm.codename,
|
||||
);
|
||||
const assignedToObject = item.objectPermissions
|
||||
.filter((uperm) => uperm.objectPk === this.objectPk)
|
||||
.some((uperm) => uperm.codename === perm.codename);
|
||||
const assignedToObject = item.objectPermissions.some(
|
||||
(uperm) => uperm.codename === perm.codename,
|
||||
);
|
||||
|
||||
let tooltip: string | null = null;
|
||||
if (assignedToModel && assignedToObject) {
|
||||
|
||||
@@ -51,11 +51,6 @@ export class NavigationButtons extends WithSession(AKElement) {
|
||||
Styles,
|
||||
];
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.refreshNotifications();
|
||||
}
|
||||
|
||||
protected async refreshNotifications(): Promise<void> {
|
||||
const { currentUser } = this;
|
||||
|
||||
|
||||
@@ -4183,6 +4183,9 @@ neprojde, když jedna nebo obě z vybraných možností jsou rovny nebo nad prah
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4281,6 +4284,12 @@ neprojde, když jedna nebo obě z vybraných možností jsou rovny nebo nad prah
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9541,6 +9550,9 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9673,6 +9685,9 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9886,41 +9901,6 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4214,6 +4214,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4312,6 +4315,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9581,6 +9590,9 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9713,6 +9725,9 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9926,41 +9941,6 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -3220,6 +3220,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -3316,6 +3319,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -7374,6 +7383,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -7506,6 +7518,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -7719,41 +7734,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4151,6 +4151,9 @@ no se aprueba cuando una o ambas de las opciones seleccionadas son iguales o sup
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4249,6 +4252,12 @@ no se aprueba cuando una o ambas de las opciones seleccionadas son iguales o sup
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9501,6 +9510,9 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9633,6 +9645,9 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9846,41 +9861,6 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4293,6 +4293,9 @@ läpäisy estyy kun jompi kumpi tai molemmat vaihtoehdot ylittävät raja-arvon.
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4391,6 +4394,12 @@ läpäisy estyy kun jompi kumpi tai molemmat vaihtoehdot ylittävät raja-arvon.
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9764,6 +9773,9 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9896,6 +9908,9 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -10109,41 +10124,6 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4282,6 +4282,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4380,6 +4383,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9749,6 +9758,9 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9881,6 +9893,9 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -10094,41 +10109,6 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4110,6 +4110,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4208,6 +4211,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9449,6 +9458,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9581,6 +9593,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9794,41 +9809,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4284,6 +4284,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4382,6 +4385,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9743,6 +9752,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9875,6 +9887,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -10088,41 +10103,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -3951,6 +3951,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4049,6 +4052,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9074,6 +9083,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9206,6 +9218,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9419,41 +9434,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -3789,6 +3789,9 @@ slaagt niet wanneer een of beide geselecteerde opties gelijk zijn aan of boven d
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -3887,6 +3890,12 @@ slaagt niet wanneer een of beide geselecteerde opties gelijk zijn aan of boven d
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -8722,6 +8731,9 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -8854,6 +8866,9 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9067,41 +9082,6 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -3966,6 +3966,9 @@ nie przechodzi, gdy jedna lub obie wybrane opcje są równe lub wyższe od progu
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4064,6 +4067,12 @@ nie przechodzi, gdy jedna lub obie wybrane opcje są równe lub wyższe od progu
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9104,6 +9113,9 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9236,6 +9248,9 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9449,41 +9464,6 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4287,6 +4287,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4385,6 +4388,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9732,6 +9741,9 @@ por exemplo: <x id="0" equiv-text="<code>"/>oci://registry.domain.tld/path
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9864,6 +9876,9 @@ por exemplo: <x id="0" equiv-text="<code>"/>oci://registry.domain.tld/path
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -10077,41 +10092,6 @@ por exemplo: <x id="0" equiv-text="<code>"/>oci://registry.domain.tld/path
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4004,6 +4004,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4102,6 +4105,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9192,6 +9201,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9324,6 +9336,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9537,41 +9552,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -3983,6 +3983,9 @@ Belirlenen seçeneklerden biri veya her ikisi de eşiğe eşit veya eşiğin üz
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4081,6 +4084,12 @@ Belirlenen seçeneklerden biri veya her ikisi de eşiğe eşit veya eşiğin üz
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9170,6 +9179,9 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9302,6 +9314,9 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9515,41 +9530,6 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -4250,6 +4250,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -4348,6 +4351,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -9704,6 +9713,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -9836,6 +9848,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -10049,41 +10064,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -3821,6 +3821,9 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="sf96a86df0756bc7b">
|
||||
<source>Afterwards, select the enrollment token you want to use:</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s13b4cf044a01dde2">
|
||||
<source>Then download the configuration to deploy the authentik Agent</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s12f523a52b843ea2">
|
||||
<source>macOS</source>
|
||||
</trans-unit>
|
||||
@@ -3919,6 +3922,12 @@ doesn't pass when either or both of the selected options are equal or above the
|
||||
<trans-unit id="s94a50f1495fb5ccd">
|
||||
<source>Disk encryption</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s76ea179414f2b2a5">
|
||||
<source>Disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s25e7a078391a3ec3">
|
||||
<source>Disk usage</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s416211a967a6db4e">
|
||||
<source>Users / Groups</source>
|
||||
</trans-unit>
|
||||
@@ -8785,6 +8794,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s6df8326edea3b23d">
|
||||
<source>If no device was provided, this stage will stop flow execution.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s506c7d2e87f6770e">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, and underscores</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s3a3d5b2575cd32ea">
|
||||
<source>File uploaded successfully</source>
|
||||
</trans-unit>
|
||||
@@ -8917,6 +8929,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="sc47f8ab6162bb2bb">
|
||||
<source>Outpost configuration</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s184c3b30bebb2dd8">
|
||||
<source>Assign role permissions</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s11a2ac9f2bd811d8">
|
||||
<source>Delete Object Permission</source>
|
||||
</trans-unit>
|
||||
@@ -9130,41 +9145,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s46a03121a2c260ea">
|
||||
<source>Buffer PolicyAccessView requests</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s361e9d929ee925e6">
|
||||
<source>Assign Object Permission</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s5f6ad947b4824e40">
|
||||
<source>Next, download the configuration to deploy the authentik Agent via MDM</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s523337424b694d5c">
|
||||
<source>Device Access Group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7642bf28cf8f476c">
|
||||
<source>Select a device access group to be added to upon enrollment.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="h30bbf18fbf87fa57">
|
||||
<source>To create a data export, navigate to
|
||||
<x id="0" equiv-text="<a href="#/identity/users">"/>Directory > Users<x id="1" equiv-text="</a>"/> or to
|
||||
<x id="2" equiv-text="<a href="#/events/log">"/>Events > Logs<x id="3" equiv-text="</a>"/>.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s813faec5ff1d32d1">
|
||||
<source>Filename can only contain letters, numbers, dots, hyphens, underscores, and slashes</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s54af3ec70642782c">
|
||||
<source>Choose the object permissions that you want the selected role to have on this object. These object permissions are in addition to any global permissions already within the role.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb3be65525dd1f92c">
|
||||
<source>Assign object permissions to role</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sbcd108b66363075c">
|
||||
<source>Device access group</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s325acae04cdcac57">
|
||||
<source>Primary disk size</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb4a957846c89fca1">
|
||||
<source>Primary disk usage</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -36,8 +36,6 @@ Firefox has some known issues regarding TouchID (see https://bugzilla.mozilla.or
|
||||
|
||||
Passwordless authentication currently only supports WebAuthn devices, which provides for the use of passkeys, security keys and biometrics. For an alternate passwordless setup, see [Password stage](../password/index.md#passwordless-login), which supports other types.
|
||||
|
||||
If you want users to authenticate with a passkey via the browser's built-in passkey/autofill UI on the **Identification** screen ("conditional UI" / passkey autofill), configure it in the [Identification stage](../identification/index.mdx#passkey-autofill-webauthn-conditional-ui). This requires a **discoverable credential (aka resident key)**.
|
||||
|
||||
To configure passwordless authentication, create a new Flow with the designation set to _Authentication_.
|
||||
|
||||
As first stage, add an _Authenticator validation_ stage, with the WebAuthn device class allowed.
|
||||
|
||||
@@ -26,46 +26,6 @@ The CAPTCHA stage you use must be configured to use the "Invisible" mode, otherw
|
||||
|
||||
To run a CAPTCHA process in the background while the user is entering their identification, a CAPTCHA stage can be selected here. If a CAPTCHA stage is selected in the Identification stage, the CAPTCHA stage should not be bound to the flow.
|
||||
|
||||
## Passkey autofill (WebAuthn conditional UI):ak-version[2025.12]
|
||||
|
||||
When configured, the Identification stage can offer passkey login directly from the browser's passkey/autofill UI (also known as "conditional UI"). This allows a user to select a passkey without first typing their username.
|
||||
|
||||
authentik will automatically fall back to the normal identification flow when passkey autofill is not available.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **HTTPS** is required for WebAuthn (except on `localhost`).
|
||||
- **Browser support** for WebAuthn conditional mediation is required.
|
||||
- Users must have a compatible **discoverable credential (aka resident key)** (most passkeys created by platform authenticators and password managers are discoverable).
|
||||
- **Correct domain**: users must access authentik using the same hostname the passkey was created for.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Log in to authentik as an administrator and open the authentik Admin interface.
|
||||
2. Navigate to **Flows and Stages** > **Stages** and either create or edit an [Authenticator validation stage](../authenticator_validate/index.mdx) that allows the **WebAuthn** device class.
|
||||
3. Navigate to **Flows and Stages** > **Stages** and edit your Identification stage. Under **Passkey settings** set **WebAuthn Authenticator Validation Stage** to the Authenticator validation stage from step 2.
|
||||
4. Click **Update** to save the changes.
|
||||
5. Ensure users have enrolled a passkey/WebAuthn device (for example using the [WebAuthn / FIDO2 / Passkeys Authenticator setup stage](../authenticator_webauthn/index.mdx)).
|
||||
|
||||
### Notes
|
||||
|
||||
- The passkey prompt is triggered by the browser when the user focuses the username field.
|
||||
- If a user has multiple passkeys, the browser will show a picker.
|
||||
- If passkey login is used, the flow context will have `auth_method` set to `auth_webauthn_pwl`.
|
||||
- In the default authentication flow blueprint, authentik skips the MFA validation stage after passkey login using an expression policy. If you want passkey login to still require an additional factor, disable or adjust that policy binding on the MFA stage.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **No passkey prompt appears**
|
||||
- Ensure the Identification stage has **WebAuthn Authenticator Validation Stage** set.
|
||||
- Ensure you're using **HTTPS** (except on `localhost`).
|
||||
- Check browser support for conditional UI.
|
||||
- Ensure the login page is not embedded in an iframe as some browsers block conditional UI outside top-level browsing contexts.
|
||||
|
||||
- **Passkey prompt appears, but login falls back to username/password**
|
||||
- Ensure the referenced Authenticator validation stage allows the **WebAuthn** device class.
|
||||
- Ensure the user has a valid, confirmed WebAuthn device enrolled.
|
||||
|
||||
## Enrollment/Recovery Flow
|
||||
|
||||
These fields specify if and which flows are linked on the form. The enrollment flow is linked as `Need an account? Sign up.`, and the recovery flow is linked as `Forgot username or password?`.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user