mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
Merge branch 'main' into guacamole-integration
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
This commit is contained in:
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
||||
custom: https://goauthentik.io/pricing/
|
||||
github: goauthentik
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/issue_template.md
vendored
7
.github/ISSUE_TEMPLATE/issue_template.md
vendored
@@ -1,7 +0,0 @@
|
||||
---
|
||||
name: Blank issue
|
||||
about: This issue type is only for internal use
|
||||
title:
|
||||
labels:
|
||||
assignees:
|
||||
---
|
||||
274
.github/actions/cherry-pick/action.yml
vendored
274
.github/actions/cherry-pick/action.yml
vendored
@@ -1,274 +0,0 @@
|
||||
name: "Cherry-picker"
|
||||
description: "Cherry-pick PRs based on their labels"
|
||||
|
||||
inputs:
|
||||
token:
|
||||
description: "GitHub Token"
|
||||
required: true
|
||||
git_user:
|
||||
description: "Git user for pushing the cherry-pick PR"
|
||||
required: true
|
||||
git_user_email:
|
||||
description: "Git user email for pushing the cherry-pick PR"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Check if workflow should run
|
||||
id: should_run
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
# Untrusted/event-derived values are passed via the environment (never
|
||||
# interpolated into the script body) to avoid template injection.
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
LABEL_NAME_CTX: ${{ github.event.label.name }}
|
||||
PR_NUMBER_CTX: ${{ github.event.pull_request.number }}
|
||||
MERGE_COMMIT_SHA_CTX: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
PR_MERGED_CTX: ${{ github.event.pull_request.merged }}
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
# For issues events, check if it's actually a PR
|
||||
if [ "$EVENT_NAME" = "issues" ]; then
|
||||
# Check if this issue is actually a PR
|
||||
PR_DATA=$(gh api "repos/${REPOSITORY}/pulls/${ISSUE_NUMBER}" 2>/dev/null || echo "null")
|
||||
if [ "$PR_DATA" = "null" ]; then
|
||||
echo "should_run=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=not_a_pr" >> $GITHUB_OUTPUT
|
||||
echo "This is an issue, not a PR. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get PR data
|
||||
PR_MERGED=$(echo "$PR_DATA" | jq -r '.merged')
|
||||
PR_NUMBER="$ISSUE_NUMBER"
|
||||
MERGE_COMMIT_SHA=$(echo "$PR_DATA" | jq -r '.merge_commit_sha')
|
||||
|
||||
# Check if it's a backport label
|
||||
LABEL_NAME="$LABEL_NAME_CTX"
|
||||
if [[ "$LABEL_NAME" =~ ^backport/(.+)$ ]]; then
|
||||
if [ "$PR_MERGED" = "true" ]; then
|
||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=label_added_to_merged_pr" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "merge_commit_sha=$MERGE_COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo "should_run=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=label_added_to_open_pr" >> $GITHUB_OUTPUT
|
||||
echo "Backport label added to open PR. Will run after PR is merged."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "should_run=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=non_backport_label" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# For pull_request and pull_request_target events
|
||||
PR_NUMBER="$PR_NUMBER_CTX"
|
||||
MERGE_COMMIT_SHA="$MERGE_COMMIT_SHA_CTX"
|
||||
|
||||
# Case 1: PR was just merged (closed + merged = true)
|
||||
if [ "$EVENT_ACTION" = "closed" ] && [ "$PR_MERGED_CTX" = "true" ]; then
|
||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=pr_merged" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "merge_commit_sha=$MERGE_COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Case 2: Label was added
|
||||
if [ "$EVENT_ACTION" = "labeled" ]; then
|
||||
LABEL_NAME="$LABEL_NAME_CTX"
|
||||
# Check if it's a backport label
|
||||
if [[ "$LABEL_NAME" =~ ^backport/(.+)$ ]]; then
|
||||
# Check if PR is already merged
|
||||
if [ "$PR_MERGED_CTX" = "true" ]; then
|
||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=label_added_to_merged_pr" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "merge_commit_sha=$MERGE_COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo "should_run=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=label_added_to_open_pr" >> $GITHUB_OUTPUT
|
||||
echo "Backport label added to open PR. Will run after PR is merged."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "should_run=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=non_backport_label" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "should_run=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=unknown" >> $GITHUB_OUTPUT
|
||||
- name: Configure Git
|
||||
if: steps.should_run.outputs.should_run == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
user: ${{ inputs.git_user }}
|
||||
email: ${{ inputs.git_user_email }}
|
||||
run: |
|
||||
git config --global user.name "${user}"
|
||||
git config --global user.email "${email}"
|
||||
- name: Get PR details and extract backport labels
|
||||
if: steps.should_run.outputs.should_run == 'true'
|
||||
id: pr_details
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
PR_NUMBER: ${{ steps.should_run.outputs.pr_number }}
|
||||
REASON: ${{ steps.should_run.outputs.reason }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
LABEL_NAME_CTX: ${{ github.event.label.name }}
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
|
||||
# Determine which labels to process
|
||||
if [ "${REASON}" = "label_added_to_merged_pr" ]; then
|
||||
LABEL_NAME="$LABEL_NAME_CTX"
|
||||
|
||||
if [[ "$LABEL_NAME" =~ ^backport/(.+)$ ]]; then
|
||||
echo "labels=$LABEL_NAME" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Label $LABEL_NAME does not match backport pattern"
|
||||
echo "labels=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
# PR was just merged, process all backport labels
|
||||
LABELS=$(gh pr view $PR_NUMBER --json labels --jq '.labels[].name' | grep '^backport/' | tr '\n' ' ' || true)
|
||||
echo "labels=$LABELS" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Cherry-pick to target branches
|
||||
if: steps.should_run.outputs.should_run == 'true' && steps.pr_details.outputs.labels != ''
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
PR_NUMBER: '${{ steps.should_run.outputs.pr_number }}'
|
||||
COMMIT_SHA: '${{ steps.should_run.outputs.merge_commit_sha }}'
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
LABELS: '${{ steps.pr_details.outputs.labels }}'
|
||||
REASON: '${{ steps.should_run.outputs.reason }}'
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
|
||||
echo "Processing PR #$PR_NUMBER (reason: ${REASON})"
|
||||
echo "Found backport labels: $LABELS"
|
||||
|
||||
# Process each backport label
|
||||
for label in $LABELS; do
|
||||
if [[ "$label" =~ ^backport/(.+)$ ]]; then
|
||||
TARGET_BRANCH="${BASH_REMATCH[1]}"
|
||||
echo "Processing backport to branch: $TARGET_BRANCH"
|
||||
|
||||
# Check if target branch exists
|
||||
if ! git ls-remote --heads origin "$TARGET_BRANCH" | grep -q "$TARGET_BRANCH"; then
|
||||
echo "❌ Target branch $TARGET_BRANCH does not exist, skipping"
|
||||
|
||||
# Comment on the original PR about the missing branch
|
||||
gh pr comment $PR_NUMBER --body "⚠️ Cannot backport to \`$TARGET_BRANCH\`: branch does not exist."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create a unique branch name for the cherry-pick
|
||||
CHERRY_PICK_BRANCH="cherry-pick/${PR_NUMBER}-to-${TARGET_BRANCH}"
|
||||
|
||||
# Check if a cherry-pick PR already exists
|
||||
EXISTING_PR=$(gh pr list --head "$CHERRY_PICK_BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "⚠️ Cherry-pick PR already exists: #$EXISTING_PR"
|
||||
gh pr comment $PR_NUMBER --body "Cherry-pick to \`$TARGET_BRANCH\` already exists: #$EXISTING_PR"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Fetch and checkout target branch
|
||||
git fetch origin "$TARGET_BRANCH"
|
||||
git checkout -b "$CHERRY_PICK_BRANCH" "origin/$TARGET_BRANCH"
|
||||
|
||||
# Attempt cherry-pick
|
||||
if git cherry-pick "$COMMIT_SHA"; then
|
||||
echo "✅ Cherry-pick successful for $TARGET_BRANCH"
|
||||
|
||||
# Push the cherry-pick branch
|
||||
git push origin "$CHERRY_PICK_BRANCH"
|
||||
|
||||
# Create PR for the cherry-pick
|
||||
CHERRY_PICK_TITLE="$PR_TITLE (cherry-pick #$PR_NUMBER to $TARGET_BRANCH)"
|
||||
CHERRY_PICK_BODY="Cherry-pick of #$PR_NUMBER to \`$TARGET_BRANCH\` branch.
|
||||
|
||||
**Original PR:** #$PR_NUMBER
|
||||
**Original Author:** @$PR_AUTHOR
|
||||
**Cherry-picked commit:** $COMMIT_SHA"
|
||||
|
||||
NEW_PR=$(gh pr create \
|
||||
--title "$CHERRY_PICK_TITLE" \
|
||||
--body "$CHERRY_PICK_BODY" \
|
||||
--base "$TARGET_BRANCH" \
|
||||
--head "$CHERRY_PICK_BRANCH" \
|
||||
--label "cherry-pick")
|
||||
|
||||
# Assign the PR to the original author
|
||||
gh pr edit "$NEW_PR" --add-assignee "$PR_AUTHOR" || true
|
||||
|
||||
echo "✅ Created cherry-pick PR $NEW_PR for $TARGET_BRANCH"
|
||||
|
||||
# Comment on original PR
|
||||
gh pr comment $PR_NUMBER --body "🍒 Cherry-pick to \`$TARGET_BRANCH\` created: $NEW_PR"
|
||||
|
||||
else
|
||||
echo "⚠️ Cherry-pick failed for $TARGET_BRANCH, creating conflict resolution PR"
|
||||
|
||||
# Add conflicted files and commit
|
||||
git add .
|
||||
git commit -m "Cherry-pick #$PR_NUMBER to $TARGET_BRANCH (with conflicts)
|
||||
|
||||
This cherry-pick has conflicts that need manual resolution.
|
||||
|
||||
Original PR: #$PR_NUMBER
|
||||
Original commit: $COMMIT_SHA"
|
||||
|
||||
# Push the branch with conflicts
|
||||
git push origin "$CHERRY_PICK_BRANCH"
|
||||
|
||||
# Create PR with conflict notice
|
||||
CONFLICT_TITLE="$PR_TITLE (cherry-pick #$PR_NUMBER to $TARGET_BRANCH)"
|
||||
CONFLICT_BODY="⚠️ **This cherry-pick has conflicts that require manual resolution.**
|
||||
|
||||
Cherry-pick of #$PR_NUMBER to \`$TARGET_BRANCH\` branch.
|
||||
|
||||
**Original PR:** #$PR_NUMBER
|
||||
**Original Author:** @$PR_AUTHOR
|
||||
**Cherry-picked commit:** $COMMIT_SHA
|
||||
|
||||
**Please resolve the conflicts in this PR before merging.**"
|
||||
|
||||
NEW_PR=$(gh pr create \
|
||||
--title "$CONFLICT_TITLE" \
|
||||
--body "$CONFLICT_BODY" \
|
||||
--base "$TARGET_BRANCH" \
|
||||
--head "$CHERRY_PICK_BRANCH" \
|
||||
--label "cherry-pick")
|
||||
|
||||
# Assign the PR to the original author
|
||||
gh pr edit "$NEW_PR" --add-assignee "$PR_AUTHOR" || true
|
||||
|
||||
echo "⚠️ Created conflict resolution PR $NEW_PR for $TARGET_BRANCH"
|
||||
|
||||
# Comment on original PR
|
||||
gh pr comment $PR_NUMBER --body "⚠️ Cherry-pick to \`$TARGET_BRANCH\` has conflicts: $NEW_PR"
|
||||
fi
|
||||
|
||||
# Clean up - go back to main branch
|
||||
git checkout main
|
||||
git branch -D "$CHERRY_PICK_BRANCH" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
48
.github/actions/compute-container-tags/action.yml
vendored
Normal file
48
.github/actions/compute-container-tags/action.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: Compute container tags
|
||||
description: Compute container tags
|
||||
|
||||
inputs: {}
|
||||
outputs:
|
||||
safe-branch-name:
|
||||
description: "Safe branch name"
|
||||
value: "${{ steps.compute-tags.outputs.safe-branch-name }}"
|
||||
tag-full:
|
||||
description: "Tag in the form gh-<commit sha>"
|
||||
value: "${{ steps.compute-tags.outputs.tag-full }}"
|
||||
tag-branch:
|
||||
description: "Tag in the form gh-<safe-branch-name>"
|
||||
value: "${{ steps.compute-tags.outputs.tag-branch }}"
|
||||
tag-flux:
|
||||
description: "Tag in the form gh-<safe-branch-name>-<timestamp>-<short sha>"
|
||||
value: "${{ steps.compute-tags.outputs.tag-flux }}"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- id: compute-tags
|
||||
shell: python
|
||||
env:
|
||||
SHA: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}"
|
||||
run: |
|
||||
import os
|
||||
import re
|
||||
from time import time
|
||||
|
||||
sha = os.environ["SHA"]
|
||||
short_sha = sha[:7]
|
||||
|
||||
branch_name = os.environ["GITHUB_REF"]
|
||||
if os.environ.get("GITHUB_HEAD_REF", "") != "":
|
||||
branch_name = os.environ["GITHUB_HEAD_REF"]
|
||||
safe_branch_name = re.sub(r"[^a-zA-Z0-9-]", "-", branch_name.replace("refs/heads/", ""))
|
||||
|
||||
tag_full = f"gh-{sha}"
|
||||
tag_branch = f"gh-{safe_branch_name}"
|
||||
tag_flux = f"gh-{safe_branch_name}-{int(time())}-{short_sha}"
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
|
||||
print(f"safe-branch-name={safe_branch_name}", file=_output)
|
||||
print(f"tag-full={tag_full}", file=_output)
|
||||
print(f"tag-branch={tag_branch}", file=_output)
|
||||
print(f"tag-flux={tag_flux}", file=_output)
|
||||
67
.github/actions/docker-push-variables/action.yml
vendored
67
.github/actions/docker-push-variables/action.yml
vendored
@@ -1,67 +0,0 @@
|
||||
---
|
||||
name: "Prepare docker environment variables"
|
||||
description: "Prepare docker environment variables"
|
||||
|
||||
inputs:
|
||||
image-name:
|
||||
required: true
|
||||
description: "Docker image prefix"
|
||||
image-arch:
|
||||
required: false
|
||||
description: "Docker image arch"
|
||||
release:
|
||||
required: true
|
||||
description: "True if this is a release build, false if this is a dev/PR build"
|
||||
|
||||
outputs:
|
||||
shouldPush:
|
||||
description: "Whether to push the image or not"
|
||||
value: ${{ steps.ev.outputs.shouldPush }}
|
||||
|
||||
sha:
|
||||
description: "sha"
|
||||
value: ${{ steps.ev.outputs.sha }}
|
||||
|
||||
version:
|
||||
description: "Version"
|
||||
value: ${{ steps.ev.outputs.version }}
|
||||
prerelease:
|
||||
description: "Prerelease"
|
||||
value: ${{ steps.ev.outputs.prerelease }}
|
||||
|
||||
imageTags:
|
||||
description: "Docker image tags"
|
||||
value: ${{ steps.ev.outputs.imageTags }}
|
||||
imageTagsJSON:
|
||||
description: "Docker image tags, as a JSON array"
|
||||
value: ${{ steps.ev.outputs.imageTagsJSON }}
|
||||
attestImageNames:
|
||||
description: "Docker image names used for attestation"
|
||||
value: ${{ steps.ev.outputs.attestImageNames }}
|
||||
cacheTo:
|
||||
description: "cache-to value for the docker build step"
|
||||
value: ${{ steps.ev.outputs.cacheTo }}
|
||||
imageMainTag:
|
||||
description: "Docker image main tag"
|
||||
value: ${{ steps.ev.outputs.imageMainTag }}
|
||||
imageMainName:
|
||||
description: "Docker image main name"
|
||||
value: ${{ steps.ev.outputs.imageMainName }}
|
||||
imageBuildArgs:
|
||||
description: "Docker image build args"
|
||||
value: ${{ steps.ev.outputs.imageBuildArgs }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Generate config
|
||||
id: ev
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_NAME: ${{ inputs.image-name }}
|
||||
IMAGE_ARCH: ${{ inputs.image-arch }}
|
||||
RELEASE: ${{ inputs.release }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
python3 ${{ github.action_path }}/push_vars.py
|
||||
122
.github/actions/docker-push-variables/push_vars.py
vendored
122
.github/actions/docker-push-variables/push_vars.py
vendored
@@ -1,122 +0,0 @@
|
||||
"""Helper script to get the actual branch name, docker safe"""
|
||||
|
||||
import os
|
||||
from json import dumps
|
||||
from pathlib import Path
|
||||
from sys import exit as sysexit
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
def authentik_version() -> str:
|
||||
init = Path(__file__).parent.parent.parent.parent / "authentik" / "__init__.py"
|
||||
with open(init) as f:
|
||||
content = f.read()
|
||||
locals: dict[str, Any] = {}
|
||||
exec(content, None, locals) # nosec
|
||||
return str(locals["VERSION"])
|
||||
|
||||
|
||||
def must_or_fail(input: str | None, error: str) -> str:
|
||||
if not input:
|
||||
print(f"::error::{error}")
|
||||
sysexit(1)
|
||||
return input
|
||||
|
||||
|
||||
# Decide if we should push the image or not
|
||||
should_push = True
|
||||
if len(os.environ.get("DOCKER_USERNAME", "")) < 1:
|
||||
# Don't push if we don't have DOCKER_USERNAME, i.e. no secrets are available
|
||||
should_push = False
|
||||
if (
|
||||
must_or_fail(os.environ.get("GITHUB_REPOSITORY"), "Repo required").lower()
|
||||
== "goauthentik/authentik-internal"
|
||||
):
|
||||
# Don't push on the internal repo
|
||||
should_push = False
|
||||
|
||||
branch_name = os.environ["GITHUB_REF"]
|
||||
if os.environ.get("GITHUB_HEAD_REF", "") != "":
|
||||
branch_name = os.environ["GITHUB_HEAD_REF"]
|
||||
safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-").replace("'", "-")
|
||||
|
||||
image_names = must_or_fail(os.getenv("IMAGE_NAME"), "Image name required").split(",")
|
||||
image_arch = os.getenv("IMAGE_ARCH") or None
|
||||
|
||||
is_pull_request = bool(os.getenv("PR_HEAD_SHA"))
|
||||
is_release = "dev" not in image_names[0]
|
||||
|
||||
sha = must_or_fail(
|
||||
os.environ["GITHUB_SHA"] if not is_pull_request else os.getenv("PR_HEAD_SHA"),
|
||||
"could not determine SHA",
|
||||
)
|
||||
|
||||
# 2042.1.0 or 2042.1.0-rc1
|
||||
version = authentik_version()
|
||||
# 2042.1
|
||||
version_family = ".".join(version.split("-", 1)[0].split(".")[:-1])
|
||||
prerelease = "-" in version
|
||||
|
||||
image_tags = []
|
||||
if is_release:
|
||||
for name in image_names:
|
||||
image_tags += [
|
||||
f"{name}:{version}",
|
||||
]
|
||||
if not prerelease:
|
||||
image_tags += [
|
||||
f"{name}:{version_family}",
|
||||
]
|
||||
else:
|
||||
suffix = ""
|
||||
if image_arch:
|
||||
suffix = f"-{image_arch}"
|
||||
for name in image_names:
|
||||
image_tags += [
|
||||
f"{name}:gh-{sha}{suffix}", # Used for ArgoCD and PR comments
|
||||
f"{name}:gh-{safe_branch_name}{suffix}", # For convenience
|
||||
f"{name}:gh-{safe_branch_name}-{int(time())}-{sha[:7]}{suffix}", # Use by FluxCD
|
||||
]
|
||||
|
||||
image_main_tag = image_tags[0].split(":")[-1]
|
||||
|
||||
|
||||
def get_attest_image_names(image_with_tags: list[str]) -> str:
|
||||
"""Attestation only for GHCR"""
|
||||
image_tags = []
|
||||
for image_name in set(name.split(":")[0] for name in image_with_tags):
|
||||
if not image_name.startswith("ghcr.io"):
|
||||
continue
|
||||
image_tags.append(image_name)
|
||||
return ",".join(set(image_tags))
|
||||
|
||||
|
||||
# Generate `cache-to` param
|
||||
cache_to = ""
|
||||
if should_push:
|
||||
_cache_tag = "buildcache"
|
||||
if image_arch:
|
||||
_cache_tag += f"-{image_arch}"
|
||||
cache_to = f"type=registry,ref={get_attest_image_names(image_tags)}:{_cache_tag},mode=max"
|
||||
|
||||
|
||||
image_build_args = []
|
||||
if os.getenv("RELEASE", "false").lower() == "true":
|
||||
image_build_args = [f"VERSION={os.getenv('REF')}"]
|
||||
else:
|
||||
image_build_args = [f"GIT_BUILD_HASH={sha}"]
|
||||
image_build_args_str = "\n".join(image_build_args)
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
|
||||
print(f"shouldPush={str(should_push).lower()}", file=_output)
|
||||
print(f"sha={sha}", file=_output)
|
||||
print(f"version={version}", file=_output)
|
||||
print(f"prerelease={prerelease}", file=_output)
|
||||
print(f"imageTags={','.join(image_tags)}", file=_output)
|
||||
print(f"imageTagsJSON={dumps(image_tags)}", file=_output)
|
||||
print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output)
|
||||
print(f"imageMainTag={image_main_tag}", file=_output)
|
||||
print(f"imageMainName={image_tags[0]}", file=_output)
|
||||
print(f"cacheTo={cache_to}", file=_output)
|
||||
print(f"imageBuildArgs={image_build_args_str}", file=_output)
|
||||
18
.github/actions/docker-push-variables/test.sh
vendored
18
.github/actions/docker-push-variables/test.sh
vendored
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash -x
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
# Non-pushing PR
|
||||
GITHUB_OUTPUT=/dev/stdout \
|
||||
GITHUB_REF=ref \
|
||||
GITHUB_SHA=sha \
|
||||
IMAGE_NAME=ghcr.io/goauthentik/server,authentik/server \
|
||||
GITHUB_REPOSITORY=goauthentik/authentik \
|
||||
python $SCRIPT_DIR/push_vars.py
|
||||
|
||||
# Pushing PR/main
|
||||
GITHUB_OUTPUT=/dev/stdout \
|
||||
GITHUB_REF=ref \
|
||||
GITHUB_SHA=sha \
|
||||
IMAGE_NAME=ghcr.io/goauthentik/server,authentik/server \
|
||||
GITHUB_REPOSITORY=goauthentik/authentik \
|
||||
DOCKER_USERNAME=foo \
|
||||
python $SCRIPT_DIR/push_vars.py
|
||||
77
.github/actions/setup-node/action.yml
vendored
77
.github/actions/setup-node/action.yml
vendored
@@ -1,77 +0,0 @@
|
||||
name: "Setup Node.js and NPM"
|
||||
description: "Sets up Node.js with a specific NPM version via Corepack"
|
||||
inputs:
|
||||
working-directory:
|
||||
description: "Path to the working directory containing the package.json file"
|
||||
required: false
|
||||
default: "."
|
||||
dependencies:
|
||||
required: false
|
||||
description: "List of dependencies to setup"
|
||||
default: "monorepo,working-directory"
|
||||
node-version-file:
|
||||
description: "Path to file containing the Node.js version"
|
||||
required: false
|
||||
default: "package.json"
|
||||
cache-dependency-path:
|
||||
description: "Path to dependency lock file for caching"
|
||||
required: false
|
||||
default: "package-lock.json"
|
||||
cache:
|
||||
description: "Package manager to cache"
|
||||
default: "npm"
|
||||
registry-url:
|
||||
description: "npm registry URL"
|
||||
default: "https://registry.npmjs.org"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Node.js (Corepack bootstrap)
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
with:
|
||||
node-version-file: ${{ inputs.node-version-file }}
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
cache: ${{ inputs.cache }}
|
||||
cache-dependency-path: |
|
||||
${{ inputs.cache-dependency-path }}
|
||||
${{ inputs.working-directory }}/${{ inputs.cache-dependency-path }}
|
||||
|
||||
- name: Install Corepack
|
||||
working-directory: ${{ github.workspace}}
|
||||
shell: bash
|
||||
run: | #shell
|
||||
node ./scripts/node/setup-corepack.mjs --force
|
||||
corepack enable
|
||||
- name: Lint Node.js and NPM versions
|
||||
shell: bash
|
||||
run: node ./scripts/node/lint-runtime.mjs
|
||||
- name: Setup Node.js (Monorepo Root)
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
with:
|
||||
node-version-file: ${{ inputs.node-version-file }}
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
- name: Install monorepo dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'monorepo') }}
|
||||
shell: bash
|
||||
run: | #shell
|
||||
node ./scripts/node/lint-lockfile.mjs
|
||||
corepack npm ci
|
||||
- name: Setup Node.js (Working Directory)
|
||||
if: ${{ contains(inputs.dependencies, 'working-directory') }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
with:
|
||||
node-version-file: ${{ inputs.working-directory }}/${{ inputs.node-version-file }}
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
|
||||
- name: Install working directory dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'working-directory') }}
|
||||
shell: bash
|
||||
run: | # shell
|
||||
corepack install
|
||||
|
||||
echo "node version: $(node --version)"
|
||||
echo "npm version: $(corepack npm --version)"
|
||||
|
||||
node ./scripts/node/lint-lockfile.mjs ${{ inputs.working-directory }}
|
||||
corepack npm ci --prefix ${{ inputs.working-directory }}
|
||||
51
.github/actions/setup/action.yml
vendored
51
.github/actions/setup/action.yml
vendored
@@ -23,7 +23,7 @@ runs:
|
||||
run: sudo apt-get remove --purge man-db
|
||||
- name: Install apt deps
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
uses: gerlero/apt-install@f4fa5265092af9e750549565d28c99aec7189639
|
||||
uses: gerlero/apt-install@c0fa73fe5c4a22deecf6d629565be92a15dd2026
|
||||
with:
|
||||
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext libclang-dev libkadm5clnt-mit12 libkadm5clnt7t64-heimdal libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
update: true
|
||||
@@ -37,12 +37,12 @@ runs:
|
||||
sudo rsync -a --delete /tmp/empty/ /usr/local/lib/android/
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v5
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5
|
||||
with:
|
||||
python-version-file: "${{ inputs.working-directory }}pyproject.toml"
|
||||
- name: Install Python deps
|
||||
@@ -52,31 +52,54 @@ runs:
|
||||
run: uv sync --all-extras --dev --locked
|
||||
- name: Setup rust (stable)
|
||||
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
rustflags: ""
|
||||
- name: Setup rust (nightly)
|
||||
if: ${{ contains(inputs.dependencies, 'rust-nightly') }}
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
components: rustfmt
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@56545b37b57562edd73171cb6c62cc509db4c34e # v2
|
||||
uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup pnpm
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_file: ${{ inputs.working-directory }}package.json
|
||||
- name: Pin pnpm store directory
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- name: Setup node (root, web)
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: ./.github/actions/setup-node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: web
|
||||
node-version-file: ${{ inputs.working-directory }}package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
${{ inputs.working-directory }}pnpm-lock.yaml
|
||||
${{ inputs.working-directory }}web/pnpm-lock.yaml
|
||||
- name: Install node dependencies (root, web)
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
- name: Setup go
|
||||
if: ${{ contains(inputs.dependencies, 'go') }}
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v5
|
||||
with:
|
||||
go-version-file: "${{ inputs.working-directory }}go.mod"
|
||||
cache-dependency-path: "${{ inputs.working-directory }}go.mod"
|
||||
- name: Setup docker cache
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
uses: AndreKurait/docker-cache@7a3887908bdb97935395833df69b060cfcca0f7f
|
||||
@@ -89,7 +112,15 @@ runs:
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||
docker compose -f .github/actions/setup/compose.yml up -d --wait
|
||||
corepack npm ci --prefix web
|
||||
- name: Install web dependencies
|
||||
# Only when node is also requested: pnpm is provided by the node setup
|
||||
# above, and the web workspace needs a pnpm lockfile to install from. This
|
||||
# keeps runtime-only jobs (e.g. rust, pending-migrations) and the legacy
|
||||
# stable checkout — which predates the pnpm migration — from invoking pnpm.
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') && contains(inputs.dependencies, 'node') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: pnpm --dir web install --frozen-lockfile
|
||||
- name: Generate config
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: uv run python {0}
|
||||
|
||||
23
.github/dependabot.yml
vendored
23
.github/dependabot.yml
vendored
@@ -7,9 +7,7 @@ updates:
|
||||
- /
|
||||
# Required to update composite actions
|
||||
# https://github.com/dependabot/dependabot-core/issues/6704
|
||||
- /.github/actions/cherry-pick
|
||||
- /.github/actions/setup
|
||||
- /.github/actions/docker-push-variables
|
||||
- /.github/actions/comment-pr-instructions
|
||||
- /.github/actions/test-results
|
||||
schedule:
|
||||
@@ -22,6 +20,13 @@ updates:
|
||||
- dependencies
|
||||
cooldown:
|
||||
default-days: 3
|
||||
groups:
|
||||
codeql:
|
||||
patterns:
|
||||
- "github/codeql-action/*"
|
||||
regclient:
|
||||
patterns:
|
||||
- "regclient/actions//*"
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -159,11 +164,12 @@ updates:
|
||||
|
||||
- package-ecosystem: npm
|
||||
directories:
|
||||
- "/packages/esbuild-plugin-live-reload"
|
||||
- "/packages/prettier-config"
|
||||
- "/packages/tsconfig"
|
||||
- "/packages/docusaurus-config"
|
||||
- "/packages/eslint-config"
|
||||
- "/packages/logger-js"
|
||||
- "/packages/esbuild-plugin-live-reload"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
@@ -243,6 +249,8 @@ updates:
|
||||
patterns:
|
||||
- "@docusaurus/*"
|
||||
- "@goauthentik/docusaurus-config"
|
||||
- "docusaurus-plugin-openapi-docs"
|
||||
- "docusaurus-theme-openapi-docs"
|
||||
build:
|
||||
patterns:
|
||||
- "@swc/*"
|
||||
@@ -335,8 +343,9 @@ updates:
|
||||
- /packages/client-go
|
||||
- /packages/client-rust
|
||||
- /packages/client-ts
|
||||
# - /scripts # Maybe
|
||||
- /scripts
|
||||
- /tests/e2e
|
||||
- /tests/openid_conformance
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
@@ -347,5 +356,9 @@ updates:
|
||||
- dependencies
|
||||
cooldown:
|
||||
default-days: 3
|
||||
|
||||
groups:
|
||||
openid-conformance:
|
||||
patterns:
|
||||
- registry.gitlab.com/openid/conformance-suite
|
||||
- registry.gitlab.com/openid/conformance-suite/nginx
|
||||
#endregion
|
||||
|
||||
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -24,3 +24,4 @@ Use `closes #N` to auto-close an issue on merge. Use `refs #N` for related issue
|
||||
|
||||
- [ ] The project has been linted, built, and tested (`make all`)
|
||||
- [ ] The documentation has been updated and formatted (`make docs`)
|
||||
- [ ] I have read the [AI usage policy](https://github.com/goauthentik/authentik/blob/main/AI_POLICY.md).
|
||||
|
||||
100
.github/workflows/_reusable-container-build-single.yml
vendored
Normal file
100
.github/workflows/_reusable-container-build-single.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
# Re-usable workflow for a single architecture container build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
runs-on:
|
||||
description: "Runner to use to build the image"
|
||||
type: string
|
||||
required: true
|
||||
ref:
|
||||
description: "Git ref to build the image from"
|
||||
type: string
|
||||
required: true
|
||||
image-name:
|
||||
description: "Name of the image to build"
|
||||
type: string
|
||||
required: true
|
||||
image-arch:
|
||||
description: "Target architecture to build the image. Will be prefixed by linux/ and passed to docker buildx build --platform"
|
||||
type: string
|
||||
required: true
|
||||
image-dockerfile:
|
||||
description: "Path to the Dockerfile to build from"
|
||||
type: string
|
||||
required: true
|
||||
image-build-args:
|
||||
description: "Build args to pass to Docker"
|
||||
type: string
|
||||
default: ""
|
||||
should-cache:
|
||||
description: "Whether a build cache should be created"
|
||||
type: boolean
|
||||
default: false
|
||||
cache-suffix:
|
||||
description: "Suffix to add to the buildcache tag"
|
||||
type: string
|
||||
default: ""
|
||||
outputs:
|
||||
artifact-id:
|
||||
value: "${{ jobs.build.outputs.artifact-id }}"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
# Needed to upload cache to ghcr.io
|
||||
packages: write
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
name: "Build ${{ inputs.image-name }} on ${{ inputs.image-arch }}"
|
||||
runs-on: "${{ inputs.runs-on }}"
|
||||
outputs:
|
||||
image-digest: "${{ steps.build.outputs.digest }}"
|
||||
artifact-id: "${{ steps.upload.outputs.artifact-id }}"
|
||||
steps:
|
||||
- name: Make space on disk
|
||||
run: |
|
||||
sudo mkdir -p /tmp/empty/
|
||||
sudo rsync -a --delete /tmp/empty/ /usr/local/lib/android/
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: "${{ inputs.ref }}"
|
||||
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- if: "${{ inputs.should-cache }}"
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
# inputs
|
||||
context: .
|
||||
file: "${{ inputs.image-dockerfile }}"
|
||||
build-args: "${{ inputs.image-build-args }}"
|
||||
secrets: |
|
||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
||||
# outputs
|
||||
push: false
|
||||
cache-from: |
|
||||
type=registry,ref=ghcr.io/goauthentik/dev-${{ inputs.image-name }}:buildcache-${{ inputs.image-arch }}-main
|
||||
type=registry,ref=ghcr.io/goauthentik/dev-${{ inputs.image-name }}:buildcache-${{ inputs.image-arch }}${{ inputs.cache-suffix }}
|
||||
cache-to: "${{ inputs.should-cache && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache-{1}{2},mode=max', inputs.image-name, inputs.image-arch, inputs.cache-suffix) || '' }}"
|
||||
attests: |
|
||||
type=provenance,mode=max
|
||||
outputs: |
|
||||
type=oci,dest=build/container/${{ inputs.image-name }}-${{ inputs.image-arch }}.oci.tar
|
||||
# params
|
||||
platforms: "linux/${{ inputs.image-arch }}"
|
||||
- id: upload
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
|
||||
with:
|
||||
name: "container-build-${{ inputs.image-name }}-${{ inputs.image-arch }}"
|
||||
path: build/
|
||||
if-no-files-found: error
|
||||
retention-days: 2
|
||||
include-hidden-files: true
|
||||
238
.github/workflows/_reusable-container-build.yml
vendored
Normal file
238
.github/workflows/_reusable-container-build.yml
vendored
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
# Re-usable workflow for multi architecture container build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref to build the image from"
|
||||
type: string
|
||||
required: true
|
||||
image-name:
|
||||
description: "Name of the image to build"
|
||||
type: string
|
||||
required: true
|
||||
image-dockerfile:
|
||||
description: "Path to the Dockerfile to build from"
|
||||
type: string
|
||||
required: true
|
||||
image-build-args:
|
||||
description: "Build args to pass to Docker"
|
||||
type: string
|
||||
default: ""
|
||||
should-cache:
|
||||
description: "Whether a build cache should be created"
|
||||
type: boolean
|
||||
default: false
|
||||
cache-suffix:
|
||||
description: "Suffix to add to the buildcache tag"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
uses: ./.github/workflows/_reusable-container-build-single.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
runs-on: ubuntu-latest
|
||||
ref: "${{ inputs.ref }}"
|
||||
image-name: "${{ inputs.image-name }}"
|
||||
image-arch: amd64
|
||||
image-dockerfile: "${{ inputs.image-dockerfile }}"
|
||||
image-build-args: "${{ inputs.image-build-args }}"
|
||||
should-cache: "${{ inputs.should-cache }}"
|
||||
cache-suffix: "${{ inputs.cache-suffix }}"
|
||||
|
||||
build-arm64:
|
||||
uses: ./.github/workflows/_reusable-container-build-single.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
ref: "${{ inputs.ref }}"
|
||||
image-name: "${{ inputs.image-name }}"
|
||||
image-arch: arm64
|
||||
image-dockerfile: "${{ inputs.image-dockerfile }}"
|
||||
image-build-args: "${{ inputs.image-build-args }}"
|
||||
should-cache: "${{ inputs.should-cache }}"
|
||||
cache-suffix: "${{ inputs.cache-suffix }}"
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
steps:
|
||||
- uses: regclient/actions/regctl-installer@f9ceff9bbbc63cd1008e60cec2b27627eedc7322
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
|
||||
with:
|
||||
artifact-ids: "${{ needs.build-amd64.outputs.artifact-id }},${{ needs.build-arm64.outputs.artifact-id }}"
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge and flatten architecture indexes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
image_name="${{ inputs.image-name }}"
|
||||
target="ocidir://${image_name}:build"
|
||||
|
||||
regctl image import \
|
||||
"ocidir://${image_name}-amd64:build" \
|
||||
"container/${image_name}-amd64.oci.tar"
|
||||
|
||||
regctl image import \
|
||||
"ocidir://${image_name}-arm64:build" \
|
||||
"container/${image_name}-arm64.oci.tar"
|
||||
|
||||
regctl index create "${target}"
|
||||
|
||||
add_platform() {
|
||||
local arch="$1"
|
||||
local source="ocidir://${image_name}-${arch}:build"
|
||||
local source_index
|
||||
local image_digest
|
||||
local attestation_digest
|
||||
|
||||
source_index="$(
|
||||
regctl manifest get "${source}" --format raw-body
|
||||
)"
|
||||
|
||||
# Select the runnable image manifest from the architecture index.
|
||||
image_digest="$(
|
||||
jq -er --arg arch "${arch}" '
|
||||
[
|
||||
.manifests[]
|
||||
| select(
|
||||
.mediaType
|
||||
== "application/vnd.oci.image.manifest.v1+json"
|
||||
and .platform.os == "linux"
|
||||
and .platform.architecture == $arch
|
||||
)
|
||||
| .digest
|
||||
]
|
||||
| if length == 1 then .[0]
|
||||
else error(
|
||||
"expected exactly one linux/" + $arch
|
||||
+ " image manifest"
|
||||
)
|
||||
end
|
||||
' <<<"${source_index}"
|
||||
)"
|
||||
|
||||
# Select the BuildKit attestation associated with that image.
|
||||
attestation_digest="$(
|
||||
jq -er --arg image_digest "${image_digest}" '
|
||||
[
|
||||
.manifests[]
|
||||
| select(
|
||||
.mediaType
|
||||
== "application/vnd.oci.image.manifest.v1+json"
|
||||
and .platform.os == "unknown"
|
||||
and .platform.architecture == "unknown"
|
||||
and .annotations["vnd.docker.reference.type"]
|
||||
== "attestation-manifest"
|
||||
and .annotations["vnd.docker.reference.digest"]
|
||||
== $image_digest
|
||||
)
|
||||
| .digest
|
||||
]
|
||||
| if length == 1 then .[0]
|
||||
else error(
|
||||
"expected exactly one attestation for "
|
||||
+ $image_digest
|
||||
)
|
||||
end
|
||||
' <<<"${source_index}"
|
||||
)"
|
||||
|
||||
# Add the runnable image manifest directly to the final index.
|
||||
regctl index add "${target}" \
|
||||
--ref "${source}@${image_digest}" \
|
||||
--desc-platform "linux/${arch}"
|
||||
|
||||
# Add the associated attestation directly beside the image.
|
||||
regctl index add "${target}" \
|
||||
--ref "${source}@${attestation_digest}" \
|
||||
--desc-platform "unknown/unknown" \
|
||||
--desc-annotation \
|
||||
"vnd.docker.reference.type=attestation-manifest" \
|
||||
--desc-annotation \
|
||||
"vnd.docker.reference.digest=${image_digest}"
|
||||
}
|
||||
|
||||
add_platform amd64
|
||||
add_platform arm64
|
||||
|
||||
final_index="$(
|
||||
regctl manifest get "${target}" --format raw-body
|
||||
)"
|
||||
|
||||
# Ensure the result is a flat index containing two runnable image
|
||||
# manifests and their two attestations, with no nested OCI indexes.
|
||||
jq -e '
|
||||
.mediaType == "application/vnd.oci.image.index.v1+json"
|
||||
and (.manifests | length == 4)
|
||||
and (
|
||||
[.manifests[].mediaType]
|
||||
| all(
|
||||
. == "application/vnd.oci.image.manifest.v1+json"
|
||||
)
|
||||
)
|
||||
and (
|
||||
[
|
||||
.manifests[]
|
||||
| select(.platform.os == "linux")
|
||||
| .platform.architecture
|
||||
]
|
||||
| sort
|
||||
== ["amd64", "arm64"]
|
||||
)
|
||||
and (
|
||||
[
|
||||
.manifests[]
|
||||
| select(
|
||||
.platform.os == "unknown"
|
||||
and .platform.architecture == "unknown"
|
||||
and .annotations["vnd.docker.reference.type"]
|
||||
== "attestation-manifest"
|
||||
)
|
||||
]
|
||||
| length == 2
|
||||
)
|
||||
and (
|
||||
(
|
||||
[
|
||||
.manifests[]
|
||||
| select(.platform.os == "linux")
|
||||
| .digest
|
||||
]
|
||||
| sort
|
||||
)
|
||||
==
|
||||
(
|
||||
[
|
||||
.manifests[]
|
||||
| select(
|
||||
.platform.os == "unknown"
|
||||
and .platform.architecture == "unknown"
|
||||
)
|
||||
| .annotations["vnd.docker.reference.digest"]
|
||||
]
|
||||
| sort
|
||||
)
|
||||
)
|
||||
' <<<"${final_index}"
|
||||
|
||||
mkdir -p build/container/
|
||||
|
||||
regctl image export \
|
||||
"${target}" \
|
||||
"build/container/${image_name}.oci.tar"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
|
||||
with:
|
||||
name: "container-build-${{ inputs.image-name }}"
|
||||
path: build/
|
||||
if-no-files-found: error
|
||||
retention-days: 2
|
||||
include-hidden-files: true
|
||||
102
.github/workflows/_reusable-docker-build-single.yml
vendored
102
.github/workflows/_reusable-docker-build-single.yml
vendored
@@ -1,102 +0,0 @@
|
||||
---
|
||||
# Re-usable workflow for a single-architecture build
|
||||
name: Reusable - Single-arch Container build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_name:
|
||||
required: true
|
||||
type: string
|
||||
image_arch:
|
||||
required: true
|
||||
type: string
|
||||
runs-on:
|
||||
required: true
|
||||
type: string
|
||||
registry_dockerhub:
|
||||
default: false
|
||||
type: boolean
|
||||
registry_ghcr:
|
||||
default: false
|
||||
type: boolean
|
||||
release:
|
||||
default: false
|
||||
type: boolean
|
||||
outputs:
|
||||
image-digest:
|
||||
value: ${{ jobs.build.outputs.image-digest }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ inputs.image_arch }}
|
||||
runs-on: ${{ inputs.runs-on }}
|
||||
outputs:
|
||||
image-digest: ${{ steps.push.outputs.digest }}
|
||||
permissions:
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
image-arch: ${{ inputs.image_arch }}
|
||||
release: ${{ inputs.release }}
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.registry_dockerhub }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ inputs.registry_ghcr }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
working-directory: web
|
||||
dependencies: "monorepo"
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
make gen-client-ts
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: lifecycle/container/Dockerfile
|
||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
secrets: |
|
||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
|
||||
build-args: |
|
||||
${{ steps.ev.outputs.imageBuildArgs }}
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
platforms: linux/${{ inputs.image_arch }}
|
||||
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
|
||||
cache-to: ${{ steps.ev.outputs.cacheTo }}
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
105
.github/workflows/_reusable-docker-build.yml
vendored
105
.github/workflows/_reusable-docker-build.yml
vendored
@@ -1,105 +0,0 @@
|
||||
---
|
||||
# Re-usable workflow for a multi-architecture build
|
||||
name: Reusable - Multi-arch container build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_name:
|
||||
required: true
|
||||
type: string
|
||||
registry_dockerhub:
|
||||
default: false
|
||||
type: boolean
|
||||
registry_ghcr:
|
||||
default: true
|
||||
type: boolean
|
||||
release:
|
||||
default: false
|
||||
type: boolean
|
||||
outputs: {}
|
||||
|
||||
jobs:
|
||||
build-server-amd64:
|
||||
uses: ./.github/workflows/_reusable-docker-build-single.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
image_name: ${{ inputs.image_name }}
|
||||
image_arch: amd64
|
||||
runs-on: ubuntu-latest
|
||||
registry_dockerhub: ${{ inputs.registry_dockerhub }}
|
||||
registry_ghcr: ${{ inputs.registry_ghcr }}
|
||||
release: ${{ inputs.release }}
|
||||
build-server-arm64:
|
||||
uses: ./.github/workflows/_reusable-docker-build-single.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
image_name: ${{ inputs.image_name }}
|
||||
image_arch: arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
registry_dockerhub: ${{ inputs.registry_dockerhub }}
|
||||
registry_ghcr: ${{ inputs.registry_ghcr }}
|
||||
release: ${{ inputs.release }}
|
||||
get-tags:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-server-amd64
|
||||
- build-server-arm64
|
||||
outputs:
|
||||
tags: ${{ steps.ev.outputs.imageTagsJSON }}
|
||||
shouldPush: ${{ steps.ev.outputs.shouldPush }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
merge-server:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.get-tags.outputs.shouldPush == 'true' }}
|
||||
needs:
|
||||
- get-tags
|
||||
- build-server-amd64
|
||||
- build-server-arm64
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.registry_dockerhub }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ inputs.registry_ghcr }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: int128/docker-manifest-create-action@126c2b2195800ebc112cffe9ad6c2e2cce16eff2 # v2
|
||||
id: build
|
||||
with:
|
||||
tags: ${{ matrix.tag }}
|
||||
sources: |
|
||||
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
|
||||
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
78
.github/workflows/ci-api-docs.yml
vendored
78
.github/workflows/ci-api-docs.yml
vendored
@@ -12,6 +12,10 @@ on:
|
||||
- main
|
||||
- version-*
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: "${{ !startsWith(github.ref, 'refs/heads/version-') && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/next' }}"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,34 +25,60 @@ jobs:
|
||||
command:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: website
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
website/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir website install --frozen-lockfile
|
||||
- name: Lint
|
||||
run: corepack npm run ${{ matrix.command }} --prefix website
|
||||
run: pnpm --dir website run ${{ matrix.command }}
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: website
|
||||
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
website/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir website install --frozen-lockfile
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/website/api/.docusaurus
|
||||
${{ github.workspace }}/website/api/**/.cache
|
||||
key: |
|
||||
${{ runner.os }}-docusaurus-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
||||
${{ runner.os }}-docusaurus-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-docusaurus-${{ hashFiles('**/package-lock.json') }}
|
||||
${{ runner.os }}-docusaurus-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
- name: Build API Docs via Docusaurus
|
||||
working-directory: website
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: corepack npm run build -w api
|
||||
run: pnpm run build:api
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
|
||||
with:
|
||||
name: api-docs
|
||||
@@ -60,21 +90,35 @@ jobs:
|
||||
- lint
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: website
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
website/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir website install --frozen-lockfile
|
||||
- name: Deploy Netlify (Production)
|
||||
working-directory: website/api
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
NETLIFY_SITE_ID: authentik-api-docs.netlify.app
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
run: npx netlify deploy --no-build --prod
|
||||
run: |
|
||||
npx netlify deploy --no-build -d ./build --prod
|
||||
- name: Deploy Netlify (Preview)
|
||||
if: github.event_name == 'pull_request' || github.ref != 'refs/heads/main'
|
||||
working-directory: website/api
|
||||
@@ -82,6 +126,6 @@ jobs:
|
||||
NETLIFY_SITE_ID: authentik-api-docs.netlify.app
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
run: |
|
||||
if [ -n "${VAR}" ]; then
|
||||
npx netlify deploy --no-build --alias=deploy-preview-${{ github.event.number }}
|
||||
if [ -n "${NETLIFY_AUTH_TOKEN}" ]; then
|
||||
npx netlify deploy --no-build -d ./build --alias=deploy-preview-${{ github.event.number }}
|
||||
fi
|
||||
|
||||
21
.github/workflows/ci-aws-cfn.yml
vendored
21
.github/workflows/ci-aws-cfn.yml
vendored
@@ -12,6 +12,10 @@ on:
|
||||
- main
|
||||
- version-*
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: "${{ !startsWith(github.ref, 'refs/heads/version-') && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/next' }}"
|
||||
|
||||
env:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
@@ -21,12 +25,21 @@ jobs:
|
||||
check-changes-applied:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: lifecycle/aws
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Check changes have been applied
|
||||
run: |
|
||||
uv run make aws-cfn
|
||||
@@ -37,6 +50,6 @@ jobs:
|
||||
- check-changes-applied
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
17
.github/workflows/ci-docs-source.yml
vendored
17
.github/workflows/ci-docs-source.yml
vendored
@@ -16,17 +16,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: generate docs
|
||||
run: |
|
||||
uv run make migrate
|
||||
uv run ak build_source_docs
|
||||
- name: Publish
|
||||
- uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
|
||||
id: netlify
|
||||
with:
|
||||
publish-dir: "./source_docs"
|
||||
production-deploy: true
|
||||
enable-commit-comment: false
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-status: false
|
||||
enable-github-deployment: false
|
||||
env:
|
||||
NETLIFY_SITE_ID: eb246b7b-1d83-4f69-89f7-01a936b4ca59
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
run: |
|
||||
npm install -g netlify-cli
|
||||
netlify deploy --dir=source_docs --prod
|
||||
NETLIFY_SITE_ID: eb246b7b-1d83-4f69-89f7-01a936b4ca59
|
||||
|
||||
110
.github/workflows/ci-docs.yml
vendored
110
.github/workflows/ci-docs.yml
vendored
@@ -12,6 +12,10 @@ on:
|
||||
- main
|
||||
- version-*
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: "${{ !startsWith(github.ref, 'refs/heads/version-') && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/next' }}"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,35 +27,74 @@ jobs:
|
||||
command:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: website
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
website/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir website install --frozen-lockfile
|
||||
- name: Lint
|
||||
run: corepack npm run ${{ matrix.command }} --prefix website
|
||||
run: pnpm --dir website run ${{ matrix.command }}
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
name: Setup Node.js
|
||||
with:
|
||||
working-directory: website
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
website/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir website install --frozen-lockfile
|
||||
- name: Build Documentation via Docusaurus
|
||||
run: corepack npm run build --prefix website
|
||||
run: pnpm --dir website run build
|
||||
build-integrations:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: website
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
website/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir website install --frozen-lockfile
|
||||
- name: Build Integrations via Docusaurus
|
||||
run: corepack npm run build -w integrations --prefix website
|
||||
run: pnpm --dir website run build:integrations
|
||||
build-container:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -61,45 +104,36 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- id: compute-tags
|
||||
uses: ./.github/actions/compute-container-tags
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-docs
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to Container Registry
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
if: "${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}"
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
id: push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: website/Dockerfile
|
||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
cache-to: "${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}"
|
||||
push: "${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}"
|
||||
tags: |
|
||||
ghcr.io/goauthentik/dev-docs:gh-${{ steps.compute-tags.outputs.tag-full }}
|
||||
ghcr.io/goauthentik/dev-docs:gh-${{ steps.compute-tags.outputs.tag-branch }}
|
||||
ghcr.io/goauthentik/dev-docs:gh-${{ steps.compute-tags.outputs.tag-flux }}
|
||||
ci-website-mark:
|
||||
if: always()
|
||||
needs:
|
||||
@@ -109,6 +143,6 @@ jobs:
|
||||
- build-container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
4
.github/workflows/ci-main-daily.yml
vendored
4
.github/workflows/ci-main-daily.yml
vendored
@@ -19,10 +19,10 @@ jobs:
|
||||
matrix:
|
||||
version:
|
||||
- docs
|
||||
- version-2026-2
|
||||
- version-2026-5
|
||||
- version-2026-8
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- run: |
|
||||
set -euo pipefail
|
||||
current="$(pwd)"
|
||||
|
||||
231
.github/workflows/ci-main.yml
vendored
231
.github/workflows/ci-main.yml
vendored
@@ -12,6 +12,10 @@ on:
|
||||
- main
|
||||
- version-*
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: "${{ !startsWith(github.ref, 'refs/heads/version-') && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/next' }}"
|
||||
|
||||
env:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
@@ -24,6 +28,49 @@ permissions:
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build-compute-tags:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
safe-branch-name: "${{ steps.compute-tags.outputs.safe-branch-name }}"
|
||||
tag-full: "${{ steps.compute-tags.outputs.tag-full }}"
|
||||
tag-branch: "${{ steps.compute-tags.outputs.tag-branch }}"
|
||||
tag-flux: "${{ steps.compute-tags.outputs.tag-flux }}"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- id: compute-tags
|
||||
uses: ./.github/actions/compute-container-tags
|
||||
build:
|
||||
needs:
|
||||
- build-compute-tags
|
||||
permissions:
|
||||
# Needed to upload cache to ghcr.io
|
||||
packages: write
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: server
|
||||
dockerfile: Dockerfile
|
||||
- name: proxy
|
||||
dockerfile: proxy.Dockerfile
|
||||
- name: ldap
|
||||
dockerfile: ldap.Dockerfile
|
||||
- name: radius
|
||||
dockerfile: radius.Dockerfile
|
||||
- name: rac
|
||||
dockerfile: rac.Dockerfile
|
||||
uses: ./.github/workflows/_reusable-container-build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
ref: "${{ github.ref }}"
|
||||
image-name: "${{ matrix.name }}"
|
||||
image-dockerfile: "lifecycle/container/${{ matrix.dockerfile }}"
|
||||
image-build-args: |
|
||||
GIT_BUILD_HASH=${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
should-cache: "${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}"
|
||||
cache-suffix: "-${{ needs.build-compute-tags.outputs.safe-branch-name }}"
|
||||
lint:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -35,6 +82,8 @@ jobs:
|
||||
deps: python
|
||||
- job: spellcheck
|
||||
deps: node
|
||||
- job: catalogs
|
||||
deps: node
|
||||
- job: pending-migrations
|
||||
deps: python,runtime
|
||||
- job: ruff
|
||||
@@ -51,17 +100,23 @@ jobs:
|
||||
deps: rust-nightly
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
dependencies: ${{ matrix.deps }}
|
||||
- name: Create required files
|
||||
run: |
|
||||
mkdir -p web/dist/standalone/loading
|
||||
for f in web/robots.txt web/security.txt web/dist/standalone/loading/startup.html; do
|
||||
echo empty > "$f"
|
||||
done
|
||||
- name: run job
|
||||
run: make ci-lint-${{ matrix.job }}
|
||||
test-gen:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Setup authentik env
|
||||
@@ -77,7 +132,7 @@ jobs:
|
||||
test-migrations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run migrations
|
||||
@@ -91,7 +146,7 @@ jobs:
|
||||
outputs:
|
||||
seed: ${{ steps.seed.outputs.seed }}
|
||||
test-migrations-from-stable:
|
||||
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
|
||||
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/10
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: test-make-seed
|
||||
@@ -101,9 +156,11 @@ jobs:
|
||||
psql:
|
||||
- 14-alpine
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
run_id: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
env:
|
||||
AUTHENTIK_MIGRATIONS__DANGEROUSLY_ALLOW_MULTIPLE_MAJOR_VERSION_UPGRADES: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: checkout stable
|
||||
@@ -134,6 +191,11 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
postgresql_version: ${{ matrix.psql }}
|
||||
# The stable checkout predates the pnpm migration, so its
|
||||
# package.json still pins npm as the package manager. Skip node here
|
||||
# (migrating to stable only needs Python + the database); pnpm/node is
|
||||
# set up again for the current checkout below.
|
||||
dependencies: "system,python,runtime"
|
||||
- name: run migrations to stable
|
||||
run: |
|
||||
docker ps
|
||||
@@ -159,7 +221,7 @@ jobs:
|
||||
AUTHENTIK_POSTGRESQL__TEST__NAME: authentik
|
||||
CI_TEST_SEED: ${{ needs.test-make-seed.outputs.seed }}
|
||||
CI_RUN_ID: ${{ matrix.run_id }}
|
||||
CI_TOTAL_RUNS: "5"
|
||||
CI_TOTAL_RUNS: "10"
|
||||
run: |
|
||||
uv run make ci-test
|
||||
- uses: ./.github/actions/test-results
|
||||
@@ -167,7 +229,7 @@ jobs:
|
||||
with:
|
||||
flags: unit-migrate
|
||||
test-unittest:
|
||||
name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
|
||||
name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/10
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: test-make-seed
|
||||
@@ -177,9 +239,9 @@ jobs:
|
||||
psql:
|
||||
- 14-alpine
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
run_id: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -188,7 +250,7 @@ jobs:
|
||||
env:
|
||||
CI_TEST_SEED: ${{ needs.test-make-seed.outputs.seed }}
|
||||
CI_RUN_ID: ${{ matrix.run_id }}
|
||||
CI_TOTAL_RUNS: "5"
|
||||
CI_TOTAL_RUNS: "10"
|
||||
run: |
|
||||
uv run make ci-test
|
||||
- uses: ./.github/actions/test-results
|
||||
@@ -199,7 +261,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Create k8s Kind Cluster
|
||||
@@ -217,6 +279,9 @@ jobs:
|
||||
name: test-e2e (${{ matrix.job.name }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs:
|
||||
- build-compute-tags
|
||||
- build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -252,7 +317,18 @@ jobs:
|
||||
glob: tests/e2e/test_endpoints_*
|
||||
profiles: selenium
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
|
||||
with:
|
||||
pattern: container-build-*-amd64
|
||||
merge-multiple: true
|
||||
- name: Load Docker images
|
||||
run: |
|
||||
for image in proxy ldap radius rac; do
|
||||
skopeo copy "oci-archive:container/${image}-amd64.oci.tar" "docker-daemon:ghcr.io/goauthentik/dev-${image}:${{ needs.build-compute-tags.outputs.tag-full }}"
|
||||
skopeo copy "oci-archive:container/${image}-amd64.oci.tar" "docker-daemon:ghcr.io/goauthentik/dev-${image}:${{ needs.build-compute-tags.outputs.tag-branch }}"
|
||||
done
|
||||
rm -rf container/
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env
|
||||
@@ -260,22 +336,33 @@ jobs:
|
||||
COMPOSE_PROFILES: ${{ matrix.job.profiles }}
|
||||
run: |
|
||||
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- id: cache-web
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4
|
||||
if: contains(matrix.job.profiles, 'selenium')
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package.json', 'web/pnpm-lock.yaml', 'pnpm-lock.yaml', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true' && contains(matrix.job.profiles, 'selenium')
|
||||
working-directory: web
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
run: |
|
||||
corepack npm ci
|
||||
corepack npm run build
|
||||
corepack npm run build:sfe
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build
|
||||
pnpm run build:sfe
|
||||
- name: run e2e
|
||||
run: |
|
||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
||||
@@ -295,6 +382,8 @@ jobs:
|
||||
job:
|
||||
- name: oidc_basic
|
||||
glob: tests/openid_conformance/test_oidc_basic.py
|
||||
- name: oidc_config
|
||||
glob: tests/openid_conformance/test_oidc_config.py
|
||||
- name: oidc_implicit
|
||||
glob: tests/openid_conformance/test_oidc_implicit.py
|
||||
- name: oidc_rp-initiated
|
||||
@@ -306,7 +395,7 @@ jobs:
|
||||
- name: ssf_transmitter
|
||||
glob: tests/openid_conformance/test_ssf_transmitter.py
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env (chrome, etc)
|
||||
@@ -316,20 +405,20 @@ jobs:
|
||||
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
|
||||
- name: Setup conformance suite
|
||||
run: |
|
||||
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
|
||||
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull --wait
|
||||
- id: cache-web
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/pnpm-lock.yaml', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
run: |
|
||||
corepack npm ci --prefix web
|
||||
corepack npm run build --prefix web
|
||||
corepack npm run build:sfe --prefix web
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
pnpm --dir web run build
|
||||
pnpm --dir web run build:sfe
|
||||
- name: run conformance
|
||||
run: |
|
||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
||||
@@ -348,11 +437,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
dependencies: rust,runtime
|
||||
dependencies: system,python,rust,runtime
|
||||
- name: Prepare database
|
||||
run: |
|
||||
uv run make migrate
|
||||
- name: Create required files
|
||||
run: |
|
||||
mkdir -p web/dist/standalone/loading
|
||||
for f in web/robots.txt web/security.txt web/dist/standalone/loading/startup.html; do
|
||||
echo empty > "$f"
|
||||
done
|
||||
- name: run tests
|
||||
run: |
|
||||
cargo llvm-cov --no-report nextest --workspace
|
||||
@@ -370,6 +468,7 @@ jobs:
|
||||
ci-core-mark:
|
||||
if: always()
|
||||
needs:
|
||||
- build
|
||||
- lint
|
||||
- test-gen
|
||||
- test-migrations
|
||||
@@ -377,48 +476,76 @@ jobs:
|
||||
- test-unittest
|
||||
- test-integration
|
||||
- test-e2e
|
||||
- test-rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
build:
|
||||
publish:
|
||||
if: "${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}"
|
||||
needs:
|
||||
- build-compute-tags
|
||||
- build
|
||||
- ci-core-mark
|
||||
permissions:
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
needs: ci-core-mark
|
||||
uses: ./.github/workflows/_reusable-docker-build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}
|
||||
release: false
|
||||
pr-comment:
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
name:
|
||||
- server
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
- rac
|
||||
steps:
|
||||
- uses: regclient/actions/regctl-installer@f9ceff9bbbc63cd1008e60cec2b27627eedc7322
|
||||
# logs in to ghcr.io by default
|
||||
- uses: regclient/actions/regctl-login@f9ceff9bbbc63cd1008e60cec2b27627eedc7322
|
||||
# Docker login is required for attestations
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: "${{ github.actor }}"
|
||||
password: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
|
||||
with:
|
||||
name: "container-build-${{ matrix.name }}"
|
||||
- id: publish
|
||||
name: publish
|
||||
run: |
|
||||
regctl image import "ocidir://${{ matrix.name }}:${{ needs.build-compute-tags.outputs.tag-full }}" "container/${{ matrix.name }}.oci.tar"
|
||||
echo "digest=$(regctl image digest "ocidir://${{ matrix.name }}:${{ needs.build-compute-tags.outputs.tag-full }}")" >> "$GITHUB_OUTPUT"
|
||||
regctl image import "ghcr.io/goauthentik/dev-${{ matrix.name }}:${{ needs.build-compute-tags.outputs.tag-full }}" "container/${{ matrix.name }}.oci.tar"
|
||||
regctl image import "ghcr.io/goauthentik/dev-${{ matrix.name }}:${{ needs.build-compute-tags.outputs.tag-branch }}" "container/${{ matrix.name }}.oci.tar"
|
||||
regctl image import "ghcr.io/goauthentik/dev-${{ matrix.name }}:${{ needs.build-compute-tags.outputs.tag-flux }}" "container/${{ matrix.name }}.oci.tar"
|
||||
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
|
||||
with:
|
||||
subject-name: "ghcr.io/goauthentik/dev-${{ matrix.name }}"
|
||||
subject-digest: "${{ steps.publish.outputs.digest }}"
|
||||
push-to-registry: true
|
||||
pr-comment:
|
||||
if: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}"
|
||||
needs:
|
||||
- build-compute-tags
|
||||
- publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to write comments on PRs
|
||||
pull-requests: write
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-server
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Comment on PR
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
uses: ./.github/actions/comment-pr-instructions
|
||||
with:
|
||||
tag: ${{ steps.ev.outputs.imageMainTag }}
|
||||
tag: "${{ needs.build-compute-tags.outputs.tag-full }}"
|
||||
|
||||
88
.github/workflows/ci-outpost.yml
vendored
88
.github/workflows/ci-outpost.yml
vendored
@@ -12,6 +12,10 @@ on:
|
||||
- main
|
||||
- version-*
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: "${{ !startsWith(github.ref, 'refs/heads/version-') && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/next' }}"
|
||||
|
||||
env:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
@@ -21,8 +25,8 @@ jobs:
|
||||
lint-golint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Prepare and generate API
|
||||
@@ -32,16 +36,15 @@ jobs:
|
||||
mkdir -p website/help
|
||||
touch web/dist/test website/help/test
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v8
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v8
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout 5000s --verbose
|
||||
skip-cache: true
|
||||
test-unittest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Setup authentik env
|
||||
@@ -59,70 +62,9 @@ jobs:
|
||||
- test-unittest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
build-container:
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
- ci-outpost-mark
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
- rac
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
|
||||
- name: Login to Container Registry
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
id: push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: lifecycle/container/${{ matrix.type }}.Dockerfile
|
||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
build-binary:
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
@@ -132,24 +74,18 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
- rac
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
working-directory: web
|
||||
- name: Build web
|
||||
run: corepack npm run build-proxy --prefix web
|
||||
- name: Build outpost
|
||||
run: |
|
||||
set -x
|
||||
|
||||
75
.github/workflows/ci-web.yml
vendored
75
.github/workflows/ci-web.yml
vendored
@@ -12,37 +12,67 @@ on:
|
||||
- main
|
||||
- version-*
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: "${{ !startsWith(github.ref, 'refs/heads/version-') && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/next' }}"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: web
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
web/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
- name: Lint
|
||||
run: corepack npm run lint-check --prefix web
|
||||
run: pnpm --dir web run lint-check
|
||||
- name: Check types
|
||||
run: corepack npm run tsc --prefix web
|
||||
run: pnpm --dir web run tsc
|
||||
- name: Check formatting
|
||||
run: corepack npm run prettier-check --prefix web
|
||||
run: pnpm --dir web run prettier-check
|
||||
- name: Lit analyse
|
||||
run: corepack npm run lit-analyse --prefix web
|
||||
run: pnpm --dir web run lit-analyse
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: web
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
web/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
- name: build
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
working-directory: web/
|
||||
run: corepack npm run build
|
||||
run: pnpm run build
|
||||
ci-web-mark:
|
||||
if: always()
|
||||
needs:
|
||||
@@ -50,7 +80,7 @@ jobs:
|
||||
- lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
test:
|
||||
@@ -59,10 +89,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: web
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
web/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
- name: test
|
||||
working-directory: web/
|
||||
run: corepack npm run test || exit 0
|
||||
run: pnpm exec vitest run --project "Unit Tests"
|
||||
|
||||
8
.github/workflows/gen-image-compress.yml
vendored
8
.github/workflows/gen-image-compress.yml
vendored
@@ -16,24 +16,20 @@ on:
|
||||
- "**.jpeg"
|
||||
- "**.png"
|
||||
- "**.webp"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
compress:
|
||||
name: compress
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on forks. Token will not be available. Will run on main and open a PR anyway
|
||||
if: |
|
||||
github.repository == 'goauthentik/authentik' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
if: "${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}"
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Compress images
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Setup authentik env
|
||||
|
||||
36
.github/workflows/gh-cherry-pick.yml
vendored
36
.github/workflows/gh-cherry-pick.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: GH - Cherry-pick
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed, labeled]
|
||||
|
||||
jobs:
|
||||
cherry-pick:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2
|
||||
if: ${{ env.GH_APP_ID != '' }}
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
env:
|
||||
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
if: ${{ steps.app-token.outcome != 'skipped' }}
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- id: get-user-id
|
||||
if: ${{ steps.app-token.outcome != 'skipped' }}
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: ./.github/actions/cherry-pick
|
||||
if: ${{ steps.app-token.outcome != 'skipped' }}
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
git_user: ${{ steps.app-token.outputs.app-slug }}[bot]
|
||||
git_user_email: '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
|
||||
22
.github/workflows/packages-npm-publish.yml
vendored
22
.github/workflows/packages-npm-publish.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
- packages/eslint-config/**
|
||||
- packages/prettier-config/**
|
||||
- packages/docusaurus-config/**
|
||||
- packages/logger-js/**
|
||||
- packages/esbuild-plugin-live-reload/**
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,12 +33,22 @@ jobs:
|
||||
- packages/logger-js
|
||||
- packages/esbuild-plugin-live-reload
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
working-directory: ${{ matrix.package }}
|
||||
node-version-file: package.json
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
@@ -48,6 +59,5 @@ jobs:
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ${{ matrix.package }}
|
||||
run: |
|
||||
corepack npm ci
|
||||
corepack npm run build
|
||||
corepack npm publish
|
||||
pnpm run build
|
||||
pnpm publish --no-git-checks
|
||||
|
||||
8
.github/workflows/qa-codeql.yml
vendored
8
.github/workflows/qa-codeql.yml
vendored
@@ -24,14 +24,14 @@ jobs:
|
||||
language: ["go", "javascript", "python"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
|
||||
2
.github/workflows/qa-dependency-review.yml
vendored
2
.github/workflows/qa-dependency-review.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
# Block PRs that introduce a *new* dependency with a known
|
||||
|
||||
2
.github/workflows/qa-semgrep.yml
vendored
2
.github/workflows/qa-semgrep.yml
vendored
@@ -26,5 +26,5 @@ jobs:
|
||||
image: semgrep/semgrep
|
||||
if: (github.actor != 'dependabot[bot]')
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- run: semgrep ci
|
||||
|
||||
18
.github/workflows/release-branch-off.yml
vendored
18
.github/workflows/release-branch-off.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: main
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -46,10 +46,10 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
run: |
|
||||
current_major_version="$(uv version --short | grep -oE "^[0-9]{4}\.[0-9]{1,2}")"
|
||||
git checkout -b "version-${current_major_version}"
|
||||
git push origin "version-${current_major_version}"
|
||||
gh label create "backport/version-${current_major_version}" --description "Add this label to PRs to backport changes to version-${current_major_version}" --color "fbca04"
|
||||
current_version_family="$(uv version --short | grep -oE "^[0-9]{4}\.[0-9]{1,2}")"
|
||||
git checkout -b "version-${current_version_family}"
|
||||
git push origin "version-${current_version_family}"
|
||||
gh label create "backport/version-${current_version_family}" --description "Add this label to PRs to backport changes to version-${current_version_family}" --color "fbca04"
|
||||
bump-version-pr:
|
||||
name: Open version bump PR
|
||||
needs:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
@@ -72,6 +72,12 @@ jobs:
|
||||
dependencies: "system,python,go,node,runtime,rust-nightly"
|
||||
- name: Run migrations
|
||||
run: make migrate
|
||||
- name: Update previous version
|
||||
run: |
|
||||
VERSION_FAMILY_PREVIOUS="$(grep -oE "^[0-9]{4}\.[0-9]{1,2}" ${PWD}/internal/constants/VERSION)"
|
||||
sed -i -E \
|
||||
"s/^VERSION_FAMILY_PREVIOUS = .*/VERSION_FAMILY_PREVIOUS = \"${VERSION_FAMILY_PREVIOUS}\"/" \
|
||||
"${PWD}/authentik/__init__.py"
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.next_version }}.0-rc1"
|
||||
- name: Re-generate API Clients
|
||||
|
||||
2
.github/workflows/release-next-branch.yml
vendored
2
.github/workflows/release-next-branch.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: internal-production
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: main
|
||||
- run: |
|
||||
|
||||
356
.github/workflows/release-publish.yml
vendored
356
.github/workflows/release-publish.yml
vendored
@@ -3,69 +3,32 @@ name: Release - On publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, created]
|
||||
types: published
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
uses: ./.github/workflows/_reusable-docker-build.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
with:
|
||||
image_name: ghcr.io/goauthentik/server,authentik/server
|
||||
release: true
|
||||
registry_dockerhub: true
|
||||
registry_ghcr: true
|
||||
build-docs:
|
||||
metadata:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
outputs:
|
||||
tag-name: "${{ steps.metadata.outputs.tag-name }}"
|
||||
version: "${{ steps.metadata.outputs.version }}"
|
||||
version-family: "${{ steps.metadata.outputs.version-family }}"
|
||||
release-reason: "${{ steps.metadata.outputs.release-reason }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/docs
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
id: push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: website/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
if: true
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
build-outpost:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- id: metadata
|
||||
name: Compute variables
|
||||
run: |
|
||||
tag_name="$(echo "$GITHUB_REF" | sed "s;refs/tags/;;")"
|
||||
echo "tag-name=$tag_name" >> "$GITHUB_OUTPUT"
|
||||
gh release download "$tag_name" --pattern metadata.json
|
||||
gh attestation verify metadata.json --owner goauthentik
|
||||
echo "version=$(jq -r '.version' < metadata.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "version-family=$(jq -r '.version_family' < metadata.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "release-reason=$(jq -r '.release_reason' < metadata.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "changelog-url=$(jq -r '.changelog_url' < metadata.json)" >> "$GITHUB_OUTPUT"
|
||||
publish-containers:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -77,155 +40,128 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type:
|
||||
name:
|
||||
- server
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
- rac
|
||||
needs:
|
||||
- metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: regclient/actions/regctl-installer@f9ceff9bbbc63cd1008e60cec2b27627eedc7322
|
||||
# logs in to ghcr.io by default
|
||||
- uses: regclient/actions/regctl-login@f9ceff9bbbc63cd1008e60cec2b27627eedc7322
|
||||
- uses: regclient/actions/regctl-login@f9ceff9bbbc63cd1008e60cec2b27627eedc7322
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: ./.github/actions/setup-node
|
||||
registry: docker.io
|
||||
username: "${{ secrets.DOCKER_CORP_USERNAME }}"
|
||||
password: "${{ secrets.DOCKER_CORP_PASSWORD }}"
|
||||
- name: Download release container artifact
|
||||
run: |
|
||||
gh release download "${{ needs.metadata.outputs.tag-name }}" --pattern "${{ matrix.name }}.oci.tar"
|
||||
gh attestation verify "${{ matrix.name }}.oci.tar" --owner goauthentik
|
||||
- id: publish
|
||||
name: Publish container
|
||||
run: |
|
||||
regctl image import "ocidir://${{ matrix.name }}:${{ needs.metadata.outputs.version }}" "${{ matrix.name }}.oci.tar"
|
||||
echo "digest=$(regctl image digest "ocidir://${{ matrix.name }}:${{ needs.metadata.outputs.version }}")" >> "$GITHUB_OUTPUT"
|
||||
regctl image import "ghcr.io/goauthentik/${{ matrix.name }}:${{ needs.metadata.outputs.version }}" "${{ matrix.name }}.oci.tar"
|
||||
regctl image import "docker.io/authentik/${{ matrix.name }}:${{ needs.metadata.outputs.version }}" "${{ matrix.name }}.oci.tar"
|
||||
if [ "${{ needs.metadata.outputs.release-reason }}" != "prerelease" ]; then
|
||||
regctl image import "ghcr.io/goauthentik/${{ matrix.name }}:${{ needs.metadata.outputs.version-family }}" "${{ matrix.name }}.oci.tar"
|
||||
regctl image import "docker.io/authentik/${{ matrix.name }}:${{ needs.metadata.outputs.version-family }}" "${{ matrix.name }}.oci.tar"
|
||||
fi
|
||||
# we need to re-login for the attestation to go through
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
working-directory: web
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
|
||||
with:
|
||||
subject-name: "ghcr.io/goauthentik/${{ matrix.name }}"
|
||||
subject-digest: "${{ steps.publish.outputs.digest }}"
|
||||
push-to-registry: true
|
||||
publish-docs-container:
|
||||
needs:
|
||||
- metadata
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
id: push
|
||||
- id: build
|
||||
name: Build Docker Image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
tags: |
|
||||
ghcr.io/goauthentik/docs:${{ needs.metadata.outputs.version }}
|
||||
ghcr.io/goauthentik/docs:${{ needs.metadata.outputs.version-family }}
|
||||
file: website/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
VERSION=${{ github.ref }}
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: lifecycle/container/${{ matrix.type }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v3
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
subject-name: ghcr.io/goauthentik/docs
|
||||
subject-digest: "${{ steps.build.outputs.digest }}"
|
||||
push-to-registry: true
|
||||
build-outpost-binary:
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload binaries to the release
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
goos: [linux, darwin]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
working-directory: web
|
||||
- name: Build web
|
||||
working-directory: web/
|
||||
run: |
|
||||
npm run build-proxy
|
||||
- name: Build outpost
|
||||
run: |
|
||||
set -x
|
||||
export GOOS=${{ matrix.goos }}
|
||||
export GOARCH=${{ matrix.goarch }}
|
||||
export CGO_ENABLED=0
|
||||
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
||||
- name: Upload binaries to release
|
||||
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
tag: ${{ github.ref }}
|
||||
upload-aws-cfn-template:
|
||||
permissions:
|
||||
# Needed for AWS login
|
||||
id-token: write
|
||||
contents: read
|
||||
needs:
|
||||
- build-server
|
||||
- build-outpost
|
||||
- metadata
|
||||
- publish-containers
|
||||
env:
|
||||
AWS_REGION: eu-central-1
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
- uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
- name: Upload template
|
||||
run: |
|
||||
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
|
||||
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
|
||||
test-release:
|
||||
needs:
|
||||
- build-server
|
||||
- build-outpost
|
||||
- build-outpost-binary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- name: Run test suite in final docker images
|
||||
run: |
|
||||
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> lifecycle/container/.env
|
||||
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> lifecycle/container/.env
|
||||
docker compose -f lifecycle/container/compose.yml pull -q
|
||||
docker compose -f lifecycle/container/compose.yml up --no-start
|
||||
docker compose -f lifecycle/container/compose.yml start postgresql
|
||||
docker compose -f lifecycle/container/compose.yml run -u root server test-all
|
||||
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ needs.metadata.outputs.tag-name }}.yaml
|
||||
if [ "$(gh release list --json tagName,isLatest --jq '.[] | select(.tagName=="${{ needs.metadata.outputs.tag-name }} ") | .isLatest')" = "true" ]; then
|
||||
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
|
||||
fi
|
||||
sentry-release:
|
||||
needs:
|
||||
- build-server
|
||||
- build-outpost
|
||||
- build-outpost-binary
|
||||
- publish-containers
|
||||
- metadata
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/server
|
||||
- name: Get static files from docker image
|
||||
run: |
|
||||
docker pull ${{ steps.ev.outputs.imageMainName }}
|
||||
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
|
||||
image="ghcr.io/goauthentik/server:${{ needs.metadata.outputs.version }}"
|
||||
docker pull "$image"
|
||||
container=$(docker container create "$image")
|
||||
docker cp ${container}:web/ .
|
||||
- name: Create a Sentry.io release
|
||||
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3
|
||||
@@ -235,6 +171,92 @@ jobs:
|
||||
SENTRY_ORG: authentik-security-inc
|
||||
SENTRY_PROJECT: authentik
|
||||
with:
|
||||
release: authentik@${{ steps.ev.outputs.version }}
|
||||
release: "authentik@${{ needs.metadata.outputs.version }}"
|
||||
sourcemaps: "./web/dist"
|
||||
url_prefix: "~/static/dist"
|
||||
bump-version:
|
||||
name: Bump version repository
|
||||
needs:
|
||||
- metadata
|
||||
- publish-containers
|
||||
if: ${{ needs.metadata.outputs.release-reason != 'prerelease' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
run: |
|
||||
jq \
|
||||
--arg version "${{ needs.metadata.outputs.version }}" \
|
||||
--arg changelog "See ${{ needs.metadata.outputs.changelog-url }}" \
|
||||
--arg changelog_url "${{ needs.metadata.outputs.changelog-url }}" \
|
||||
--arg reason "${{ needs.metadata.outputs.release-reason }}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url | .stable.reason = $reason' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ needs.metadata.outputs.version }}
|
||||
commit-message: "version: bump to ${{ needs.metadata.outputs.version }}"
|
||||
title: "version: bump to ${{ needs.metadata.outputs.version }}"
|
||||
body: "See ${{ needs.metadata.outputs.changelog-url }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
bump-helm:
|
||||
name: Bump Helm version
|
||||
needs:
|
||||
- metadata
|
||||
- publish-containers
|
||||
if: ${{ needs.metadata.outputs.release-reason != 'prerelease' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
run: |
|
||||
sed -i 's/^version: .*/version: ${{ needs.metadata.outputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/^appVersion: .*/appVersion: ${{ needs.metadata.outputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/upgrade to authentik .*/upgrade to authentik ${{ needs.metadata.outputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ needs.metadata.outputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ needs.metadata.outputs.version }}
|
||||
commit-message: "charts/authentik: bump to ${{ needs.metadata.outputs.version }}"
|
||||
title: "charts/authentik: bump to ${{ needs.metadata.outputs.version }}"
|
||||
body: "See ${{ needs.metadata.outputs.changelog-url }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
|
||||
353
.github/workflows/release-tag.yml
vendored
353
.github/workflows/release-tag.yml
vendored
@@ -30,40 +30,42 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
env:
|
||||
VERSION: "${{ inputs.version }}"
|
||||
run: |
|
||||
echo "${{ inputs.version }}" | grep -E '^[0-9]{4}\.(0?[1-9]|1[0-2])\.[0-9]+(-rc[0-9]+)?$'
|
||||
echo "major_version=${{ inputs.version }}" | grep -oE "^major_version=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
||||
echo "${VERSION}" | grep -E '^[0-9]{4}\.(0?[1-9]|1[0-2])\.[0-9]+(-rc[0-9]+)?$'
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "version-family=${VERSION}" | grep -oE "^version-family=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
||||
- id: check-branch
|
||||
env:
|
||||
BRANCH: "${{ github.ref_name }}"
|
||||
EXPECTED_BRANCH: "version-${{ steps.check.outputs.version-family }}"
|
||||
run: |
|
||||
if [ "${BRANCH}" != "${EXPECTED_BRANCH}" ]; then
|
||||
echo "::error::This workflow must be run on ${EXPECTED_BRANCH}, but was run on ${BRANCH}."
|
||||
exit 1
|
||||
fi
|
||||
- id: changelog-url
|
||||
run: |
|
||||
if [ "${{ inputs.release_reason }}" = "feature" ]; then
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}"
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.version-family }}"
|
||||
elif [ "${{ inputs.release_reason }}" = "prerelease" ]; then
|
||||
changelog_url="https://next.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}"
|
||||
changelog_url="https://next.goauthentik.io/docs/releases/${{ steps.check.outputs.version-family }}"
|
||||
else
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version }} | sed 's/\.//g')"
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.version-family }}#fixed-in-$(echo -n "${{ steps.check.outputs.version }}" | sed 's/\.//g')"}
|
||||
fi
|
||||
echo "changelog_url=${changelog_url}" >> "$GITHUB_OUTPUT"
|
||||
echo "changelog-url=${changelog_url}" >> "$GITHUB_OUTPUT"
|
||||
outputs:
|
||||
major_version: "${{ steps.check.outputs.major_version }}"
|
||||
changelog_url: "${{ steps.changelog-url.outputs.changelog_url }}"
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
version: "${{ steps.check.outputs.version }}"
|
||||
version-family: "${{ steps.check.outputs.version-family }}"
|
||||
changelog-url: "${{ steps.changelog-url.outputs.changelog-url }}"
|
||||
bump-version:
|
||||
name: Bump authentik version
|
||||
needs:
|
||||
- check-inputs
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release-commit: "${{ steps.commit.outputs.release-commit }}"
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
@@ -76,9 +78,9 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
ref: "version-${{ needs.check-inputs.outputs.version-family }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
@@ -87,77 +89,177 @@ jobs:
|
||||
- name: Run migrations
|
||||
run: make migrate
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.version }}"
|
||||
run: "make bump version=${{ needs.check-inputs.outputs.version }}"
|
||||
- name: Re-generate API Clients
|
||||
run: make gen
|
||||
- name: Commit and push
|
||||
- id: commit
|
||||
name: Commit and push
|
||||
run: |
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
git pull
|
||||
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
tag_name: "version/${{ inputs.version }}"
|
||||
name: Release ${{ inputs.version }}
|
||||
draft: true
|
||||
prerelease: ${{ inputs.release_reason == 'prerelease' }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
See ${{ needs.check-inputs.outputs.changelog_url }}
|
||||
bump-helm:
|
||||
name: Bump Helm version
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
run: |
|
||||
sed -i 's/^version: .*/version: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/^appVersion: .*/appVersion: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/upgrade to authentik .*/upgrade to authentik ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
title: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
body: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
bump-version:
|
||||
name: Bump version repository
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
git commit -a -m "release: ${{ needs.check-inputs.outputs.version }}" --allow-empty
|
||||
git push
|
||||
echo "release-commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
build-container:
|
||||
needs:
|
||||
- check-inputs
|
||||
- bump-authentik
|
||||
- bump-version
|
||||
permissions:
|
||||
# Needed to upload cache to ghcr.io. Even if we don't cache those builds, it's still needed
|
||||
# otherwise the underlying workflow fails with permission issues.
|
||||
packages: write
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: server
|
||||
dockerfile: Dockerfile
|
||||
- name: proxy
|
||||
dockerfile: proxy.Dockerfile
|
||||
- name: ldap
|
||||
dockerfile: ldap.Dockerfile
|
||||
- name: radius
|
||||
dockerfile: radius.Dockerfile
|
||||
- name: rac
|
||||
dockerfile: rac.Dockerfile
|
||||
uses: ./.github/workflows/_reusable-container-build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
ref: "${{ needs.bump-version.outputs.release-commit }}"
|
||||
image-name: "${{ matrix.name }}"
|
||||
image-dockerfile: "lifecycle/container/${{ matrix.dockerfile }}"
|
||||
image-build-args: |
|
||||
VERSION=${{ needs.check-inputs.outputs.version }}
|
||||
build-go-binaries:
|
||||
needs:
|
||||
- bump-version
|
||||
timeout-minutes: 120
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type:
|
||||
- ldap
|
||||
- radius
|
||||
goos: [linux, darwin]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: "${{ needs.bump-version.outputs.release-commit }}"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Build outpost
|
||||
run: |
|
||||
set -x
|
||||
mkdir -p build/binaries/
|
||||
export GOOS=${{ matrix.goos }}
|
||||
export GOARCH=${{ matrix.goarch }}
|
||||
export CGO_ENABLED=0
|
||||
go build -tags=outpost_static_embed -v -o ./build/binaries/authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
|
||||
with:
|
||||
name: "binaries-build-${{ matrix.type }}-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
path: build/
|
||||
if-no-files-found: error
|
||||
retention-days: 2
|
||||
include-hidden-files: true
|
||||
build-rust-binaries:
|
||||
needs:
|
||||
- bump-version
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type:
|
||||
- proxy
|
||||
target:
|
||||
- triple: x86_64-unknown-linux-gnu
|
||||
runs-on: ubuntu-latest
|
||||
- triple: aarch64-unknown-linux-gnu
|
||||
runs-on: ubuntu-24.04-arm
|
||||
- triple: x86_64-apple-darwin
|
||||
runs-on: macos-15-intel
|
||||
- triple: aarch64-apple-darwin
|
||||
runs-on: macos-latest
|
||||
runs-on: "${{ matrix.target.runs-on }}"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: "${{ needs.bump-version.outputs.release-commit }}"
|
||||
- name: Setup rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
with:
|
||||
rustflags: ""
|
||||
target: "${{ matrix.target.triple }}"
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Build outpost
|
||||
run: |
|
||||
set -x
|
||||
mkdir -p build/binaries/
|
||||
cargo build --package authentik --no-default-features --features "${{ matrix.type }}" --locked --release --target "${{ matrix.target.triple }}"
|
||||
cp "./target/${{ matrix.target.triple }}/release/authentik" "build/binaries/authentik-outpost-${{ matrix.type }}-${{ matrix.target.triple }}"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
|
||||
with:
|
||||
name: "binaries-build-${{ matrix.type }}-${{ matrix.target.triple }}"
|
||||
path: build/
|
||||
if-no-files-found: error
|
||||
retention-days: 2
|
||||
include-hidden-files: true
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
- bump-version
|
||||
- build-container
|
||||
steps:
|
||||
- name: Fetch container images
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
|
||||
with:
|
||||
name: container-build-server-amd64
|
||||
- name: Load container images
|
||||
run: |
|
||||
skopeo copy "oci-archive:container/server-amd64.oci.tar" "docker-daemon:authentik.invalid/goauthentik/server:${{ needs.check-inputs.outputs.version }}"
|
||||
rm -rf container/
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
ref: "${{ needs.bump-version.outputs.release-commit }}"
|
||||
- name: Run test in Docker
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: authentik-release-test
|
||||
AUTHENTIK_IMAGE: authentik.invalid/goauthentik/server
|
||||
AUTHENTIK_TAG: "${{ needs.check-inputs.outputs.version }}"
|
||||
run: ./scripts/test_docker.sh
|
||||
create-release:
|
||||
name: Create release
|
||||
needs:
|
||||
- check-inputs
|
||||
- bump-version
|
||||
- build-container
|
||||
- build-go-binaries
|
||||
- build-rust-binaries
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
@@ -165,48 +267,61 @@ jobs:
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
ref: "${{ needs.bump-version.outputs.release-commit }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
if: "${{ inputs.release_reason == 'feature' }}"
|
||||
- name: Create tag
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
||||
reason="${{ inputs.release_reason }}"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
--arg reason "${reason}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url | .stable.reason = $reason' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Bump version
|
||||
if: "${{ inputs.release_reason != 'feature' }}"
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
git tag "version/${{ needs.check-inputs.outputs.version }}" "${{ needs.bump-version.outputs.release-commit }}" -m "version/${{ needs.check-inputs.outputs.version }}"
|
||||
git push --tags
|
||||
- name: Create metadata file
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
|
||||
reason="${{ inputs.release_reason }}"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
--arg reason "${reason}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url | .stable.reason = $reason' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v7
|
||||
echo '{"version": "${{ needs.check-inputs.outputs.version }}", "version_family": "${{ needs.check-inputs.outputs.version-family }}", "release_reason": "${{ inputs.release_reason }}", "changelog_url": "${{ needs.check-inputs.outputs.changelog-url }}"}' > metadata.json
|
||||
- name: Fetch container images
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
|
||||
with:
|
||||
pattern: container-build-*
|
||||
merge-multiple: true
|
||||
- name: Fetch binaries
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5
|
||||
with:
|
||||
pattern: binaries-build-*
|
||||
merge-multiple: true
|
||||
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
|
||||
with:
|
||||
subject-path: |
|
||||
metadata.json
|
||||
container/server.oci.tar
|
||||
container/proxy.oci.tar
|
||||
container/ldap.oci.tar
|
||||
container/radius.oci.tar
|
||||
container/rac.oci.tar
|
||||
binaries/authentik-outpost-*
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "version: bump to ${{ inputs.version }}"
|
||||
title: "version: bump to ${{ inputs.version }}"
|
||||
body: "version: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
tag_name: "version/${{ needs.check-inputs.outputs.version }}"
|
||||
name: Release ${{ needs.check-inputs.outputs.version }}
|
||||
draft: true
|
||||
prerelease: ${{ inputs.release_reason == 'prerelease' }}
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
See ${{ needs.check-inputs.outputs.changelog-url }}
|
||||
files: |
|
||||
metadata.json
|
||||
container/server.oci.tar
|
||||
container/proxy.oci.tar
|
||||
container/ldap.oci.tar
|
||||
container/radius.oci.tar
|
||||
container/rac.oci.tar
|
||||
binaries/authentik-outpost-*
|
||||
|
||||
2
.github/workflows/repo-stale.yml
vendored
2
.github/workflows/repo-stale.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ steps.generate_token.outputs.token }}
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -25,11 +25,11 @@ jobs:
|
||||
with:
|
||||
client-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -14,8 +14,7 @@ media
|
||||
# Node
|
||||
|
||||
node_modules
|
||||
corepack.tgz
|
||||
.corepack
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
.cspellcache
|
||||
cspell-report.*
|
||||
@@ -244,3 +243,7 @@ source_docs/
|
||||
### Docker ###
|
||||
tests/openid_conformance/exports/*.zip
|
||||
compose.override.yml
|
||||
|
||||
# Claude Local Instructions
|
||||
# https://code.claude.com/docs/en/memory#choose-where-to-put-claude-md-files
|
||||
CLAUDE.local.md
|
||||
|
||||
21
.npmrc
21
.npmrc
@@ -1,20 +1,13 @@
|
||||
# Block lifecycle scripts (preinstall/install/postinstall/prepare) from dependencies.
|
||||
# This neutralizes the dominant npm supply-chain attack vector.
|
||||
#
|
||||
# Packages that legitimately need a build step (e.g. esbuild, chromedriver, tree-sitter)
|
||||
# must be rebuilt explicitly:
|
||||
#
|
||||
# npm rebuild --foreground-scripts esbuild chromedriver tree-sitter tree-sitter-json
|
||||
ignore-scripts=true
|
||||
|
||||
# Fail fast if the active Node/npm doesn't match the "engines" field.
|
||||
# Fail fast if the active Node/pnpm doesn't match the "engines" field.
|
||||
engine-strict=true
|
||||
|
||||
# Pin exact versions so `npm install <pkg>` writes "1.2.3" not "^1.2.3".
|
||||
# Pin exact versions so `pnpm add <pkg>` writes "1.2.3" not "^1.2.3".
|
||||
save-exact=true
|
||||
|
||||
# Surface CVE warnings during install; doesn't block.
|
||||
audit=true
|
||||
|
||||
# Suppress funding banners.
|
||||
fund=false
|
||||
|
||||
# Lifecycle scripts are blocked by default in pnpm 10+. Per-project
|
||||
# allow-lists live in the `pnpm.onlyBuiltDependencies` field of each
|
||||
# `package.json` (replaces the prior `npm rebuild --foreground-scripts`
|
||||
# dance keyed off `.npmrc`'s `ignore-scripts`).
|
||||
|
||||
@@ -23,6 +23,10 @@ website/api/reference
|
||||
## Yarn
|
||||
.yarn/**/*
|
||||
|
||||
# PNPM
|
||||
pnpm-workspace.yaml
|
||||
pnpm-lock.yaml
|
||||
|
||||
## Node
|
||||
node_modules
|
||||
coverage
|
||||
@@ -50,4 +54,3 @@ src/locales/
|
||||
# Storybook
|
||||
storybook-static/
|
||||
.storybook/css-import-maps*
|
||||
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -23,7 +23,7 @@
|
||||
"request": "attach",
|
||||
"connect": {
|
||||
"host": "localhost",
|
||||
"port": 9901
|
||||
"port": 9902
|
||||
},
|
||||
"pathMappings": [
|
||||
{
|
||||
|
||||
171
AGENTS.md
Normal file
171
AGENTS.md
Normal file
@@ -0,0 +1,171 @@
|
||||
## Project Overview
|
||||
|
||||
This is the **authentik** monorepo — an open-source Identity Provider (IdP) for modern SSO. It speaks SAML, OAuth2/OIDC, LDAP, RADIUS, and SCIM, and is built to be self-hosted from a homelab to a large production cluster. The company is **Authentik Security, Inc.**; the product name is **always lowercase `authentik`**, even at the start of a sentence.
|
||||
|
||||
It is a **polyglot monorepo**. Most work lands in one of the subtrees below; where a subtree has its own deeper guide, read it before working there:
|
||||
|
||||
| Language | Where | What it is | Deeper guide |
|
||||
| -------------- | -------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| **Python** | `authentik/`, `lifecycle/` | The core server — a Django + Django REST Framework app. The source of truth for the IdP. | — |
|
||||
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, RAC, RADIUS). | — |
|
||||
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker/proxy outpost components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
|
||||
| **TypeScript** | `web/` | The web UI — three Lit + PatternFly apps (Admin, User, Flow). | [`web/AGENTS.md`](web/AGENTS.md) |
|
||||
| **Docs** | `website/` | The documentation, integrations, and API sites (Docusaurus). | [`website/AGENTS.md`](website/AGENTS.md) |
|
||||
|
||||
The Python core and the web UI talk through a **generated OpenAPI client** — never hand-roll HTTP calls in either direction (see [API schema & clients](#api-schema--clients)).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
authentik/ # Django core — the IdP itself (see "The authentik Django package" below)
|
||||
lifecycle/ # Boot/runtime: migrations, gunicorn config, the `ak` CLI, container + AWS entrypoints
|
||||
cmd/ # Go entrypoints: ldap/ rac/ radius/ outposts
|
||||
internal/ # Shared Go: outpost implementations, config
|
||||
src/ # Rust server/worker (ak-axum based; gated behind cargo features)
|
||||
packages/ # Shared workspace packages, polyglot:
|
||||
# client-go / client-rust / client-ts — GENERATED API clients (do not hand-edit)
|
||||
# ak-axum / ak-common / ak-guardian — Rust crates
|
||||
# django-* — reusable Django apps (channels, dramatiq, cache)
|
||||
# eslint-config / prettier-config / tsconfig / theme / docusaurus-config — shared JS config
|
||||
web/ # TypeScript web UI (own AGENTS.md)
|
||||
website/ # Docs / integrations / API sites (own AGENTS.md)
|
||||
blueprints/ # YAML declarative config (default/ system/ example/) applied at startup
|
||||
locale/ # Backend translations (.po) + cspell overrides dictionary (en/dictionaries/)
|
||||
tests/ # Cross-cutting test support: e2e/, integration/, geoip/, openid_conformance/
|
||||
schemas/ # Third-party XSD/JSON schemas (SAML, WS-*, SCIM) used at runtime
|
||||
scripts/ # Repo automation (schema build, compose generation, node setup, semver)
|
||||
schema.yml # GENERATED OpenAPI schema — the contract between core and every client
|
||||
Makefile # The command hub — almost everything is a make target (see below)
|
||||
manage.py # Django management entrypoint
|
||||
pyproject.toml # Python deps + tool config (uv, black, ruff, mypy, bandit)
|
||||
Cargo.toml # Rust workspace manifest
|
||||
go.mod # Go module (module path: goauthentik.io)
|
||||
```
|
||||
|
||||
### The authentik Django package
|
||||
|
||||
`authentik/` is split into focused Django apps. The most useful landmarks:
|
||||
|
||||
- **`core/`** — users, applications, tokens, the central models everything else hangs off.
|
||||
- **`flows/`** + **`stages/`** — the flow engine (login/enrollment/recovery orchestration) and the individual stages it executes. Mirrors the web `flow/` app.
|
||||
- **`policies/`** — the policy engine that gates flows, applications, and sources.
|
||||
- **`sources/`** — inbound identity (LDAP, OAuth, SAML, SCIM, Kerberos source).
|
||||
- **`providers/`** — outbound protocols authentik exposes (SAML, OAuth2/OIDC, Proxy, LDAP, RADIUS, SCIM, RAC).
|
||||
- **`outposts/`** — management/coordination of the Go outposts.
|
||||
- **`brands/`** + **`tenants/`** — branding/theming and multi-tenancy (`django-tenants`).
|
||||
- **`blueprints/`** — the engine that applies the YAML under the top-level `blueprints/` directory.
|
||||
- **`rbac/`**, **`crypto/`**, **`events/`** (audit log), **`enterprise/`** (EE-licensed features), **`api/`** - **`admin/`** (REST surfaces), **`root/`** (Django project: settings, URLs, ASGI/WSGI).
|
||||
|
||||
## Where your change goes
|
||||
|
||||
Most tasks land in one subtree and have one follow-up step. Find the row, then read that subtree's `AGENTS.md` before working there.
|
||||
|
||||
| You want to… | Go to | Then |
|
||||
| ------------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| Add or change a REST endpoint, model field, or serializer | `authentik/` (Python) | `make gen` to refresh `schema.yml` + clients, and commit the generated migration |
|
||||
| Change UI behavior, a flow screen, or an admin page | `web/` | [`web/AGENTS.md`](web/AGENTS.md) — call the API only through `@goauthentik/api` |
|
||||
| Write or edit docs, an integration guide, or a glossary term | `website/` | [`website/AGENTS.md`](website/AGENTS.md), then `make docs` / `make integrations` |
|
||||
| Change an outpost (LDAP, proxy, RAC, RADIUS) or the front proxy | `cmd/` + `internal/` (Go) | `make go-test` |
|
||||
| Change a native server/worker component or shared crate | `src/` + `packages/ak-*` (Rust)| `make rust-test` |
|
||||
| Seed or reconcile a managed object (flow, stage, policy, brand) | `blueprints/` (YAML) | prefer a blueprint over an ad-hoc data migration |
|
||||
| Change boot, migration wiring, the `ak` CLI, or a container entry | `lifecycle/` | `make run` to confirm the server still boots |
|
||||
|
||||
A change that touches more than one row usually wants more than one PR — see [Conventions](#conventions) on splitting by `CODEOWNERS`.
|
||||
|
||||
## Commands
|
||||
|
||||
**The `Makefile` at the repo root is the command hub — run `make help` for the annotated list.** Targets wire up the right working directory, tooling, and ordering across all four languages; prefer them over invoking `uv` / `cargo` / `go` / `npm` directly. Python runs under **`uv`**; the dev server runs as `ak allinone`.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
make install # Install everything (node + web + core/Python). Run this first.
|
||||
make gen-dev-config # Generate a local development config file
|
||||
make dev-reset # Drop + recreate the Postgres DB and migrate to a fresh-install state
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
make run # Run the authentik server + worker (uv run ak allinone)
|
||||
make run-watch # Same, auto-reloading on .py/.rs/.go changes (needs watchexec)
|
||||
make migrate # Apply Django migrations
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
make test # Python/Django tests + coverage. Append a path to scope: `make test authentik/providers/saml`
|
||||
make go-test # Go tests (race + cover)
|
||||
make rust-test # Rust tests (cargo nextest)
|
||||
make web-test # Web UI tests (delegates to web/)
|
||||
```
|
||||
|
||||
### Lint & format
|
||||
|
||||
```bash
|
||||
make lint-fix # Auto-fix: black + ruff (Python) and rustfmt (Rust)
|
||||
make lint # Check: bandit, mypy --strict, golangci-lint, cargo deny/machete
|
||||
make lint-spellcheck # cspell across the repo (typo-only mode: reports known misspellings and forbidden British spellings, not unknown words)
|
||||
make lint-catalogs # pnpm catalog pins in sync across the root/web/website workspaces
|
||||
```
|
||||
|
||||
CI mirrors these as `ci-lint-*` / `ci-test` targets. Run the matching `make lint` / `make test` (plus `make web` / `make docs` for those subtrees) before pushing — CI runs the same checks.
|
||||
|
||||
## API schema & clients
|
||||
|
||||
The REST API is the contract between the Django core and everything else, and it is **generated, not authored**:
|
||||
|
||||
1. The OpenAPI schema is extracted from the running Django app into `schema.yml` (`make gen-build`).
|
||||
2. Typed clients are generated from that schema into `packages/client-{go,rust,ts}` (`make gen-clients`).
|
||||
3. `make gen` does both. The TypeScript client is published into the web build as `@goauthentik/api`.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Never hand-edit `schema.yml` or anything under `packages/client-*`** — change the Python API, then regenerate.
|
||||
- **In the web UI, only ever call the API through `@goauthentik/api`** — no `fetch`, no Axios (see [`web/AGENTS.md`](web/AGENTS.md)).
|
||||
- After changing a serializer/viewset, run `make gen` so the schema and clients stay in sync; `make ci-lint-pending-migrations` likewise guards against uncommitted model migrations.
|
||||
|
||||
## Blueprints
|
||||
|
||||
`blueprints/` holds **declarative YAML** that authentik applies at startup to seed and reconcile objects (flows, stages, policies, default brands). `default/` and `system/` ship the built-in setup; `example/` is reference; `testing/` backs tests. Prefer changing the system via a blueprint over an ad-hoc data migration when the result should be a managed, idempotent object.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Product name is always lowercase `authentik`.** This holds in code comments, docs, and commit messages.
|
||||
- **Commit attribution:** do not add a Claude co-author trailer; credit human collaborators instead.
|
||||
- **`CODEOWNERS`** maps subtrees to teams. For a change that spans several teams' areas, prefer splitting into one PR per owning team (enabling/wiring changes merge last).
|
||||
- **Translations** (`locale/`) and web locales are extracted, not edited by hand — see `make i18n-extract`.
|
||||
- When you change a documented workflow (a command, a path, a convention), update this file **and** the relevant sub-`AGENTS.md` / developer doc so they don't drift.
|
||||
|
||||
## Documentation pointers
|
||||
|
||||
Authoritative contributor docs live under `website/docs/developer-docs/` and are published at <https://docs.goauthentik.io/docs/developer-docs/>:
|
||||
|
||||
- `setup/full-dev-environment.mdx` — full backend + frontend dev environment.
|
||||
- `setup/frontend-dev-environment.mdx` — web-only setup.
|
||||
- `setup/debugging.mdx` — attaching a debugger (VS Code config included).
|
||||
- `docs/style-guide.mdx` — the canonical prose style guide (also governs this repo's docs).
|
||||
- `contributing.mdx` / top-level `CONTRIBUTING.md` — contribution process. `SECURITY.md` — reporting vulnerabilities.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Concern | Tooling |
|
||||
| --------------- | ------------------------------------------------------------------------ |
|
||||
| Core server | Python 3.14, Django 5.2 + Django REST Framework, Channels (ASGI) |
|
||||
| Background work | Dramatiq (Postgres broker) |
|
||||
| Datastore | PostgreSQL (multi-tenant via `django-tenants`) |
|
||||
| Outposts | Go 1.26 (`goauthentik.io` module) — LDAP, proxy, RAC, RADIUS |
|
||||
| Native services | Rust (2024 edition, `axum`) — server/worker components + shared crates |
|
||||
| Web UI | TypeScript, Lit 3, PatternFly 4 (see `web/`) |
|
||||
| Docs | Docusaurus 3 (see `website/`) |
|
||||
| API | OpenAPI (`drf-spectacular`) → generated Go/Rust/TS clients |
|
||||
| Python tooling | `uv`, black, ruff, mypy (`--strict`), bandit |
|
||||
| Build hub | GNU Make + per-language toolchains |
|
||||
| CI / hosting | GitHub Actions; distributed as Docker images and a Helm chart |
|
||||
|
||||
## Issue and PR Guidelines
|
||||
|
||||
- Never create an issue.
|
||||
- Never create a PR.
|
||||
- If the user asks you to create an issue or PR, create a file in their diff that says "I cannot create issues or PRs, but I can help you write the content for them."
|
||||
21
AI_POLICY.md
Normal file
21
AI_POLICY.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# AI-Assisted Contributions Policy
|
||||
|
||||
authentik welcomes community contributions, including AI-assisted contributions, as long as contributors understand, review, and take responsibility for what they submit.
|
||||
|
||||
- **All AI usage in any form must be disclosed.** State what tool(s) you relied on and the extent that the work was AI-assisted.
|
||||
- **The human-in-the-loop must fully understand all code.** If you cannot explain what your changes do, why they are correct, and how they affect the relevant parts of authentik without AI assistance, do not submit them.
|
||||
- **Issues and discussions can use AI assistance but must have a human-in-the-loop.** This means that any content generated with AI must have been reviewed _and edited_ by a human before submission. AI is often overly verbose, with noise that distracts from the main point. Contributors are expected to verify claims, include relevant context, and remove generic or speculative content.
|
||||
- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the only acceptable AI-generated content.
|
||||
- If a code contribution or discussion appears to be entirely AI-driven and lacking human judgement, the maintainers will close the issue/PR/discussion. Common examples include unexplained large diffs, invented API endpoints, irrelevant changes, generic issue reports, repeated AI-generated replies, or contributors being unable to answer review questions.
|
||||
|
||||
These rules apply to all external contributions to authentik. Our [maintainers](https://github.com/orgs/goauthentik/people) use AI tools at the internal team's discretion; we are already constantly discussing our work with each other and checking in as humans.
|
||||
|
||||
## There are Humans Here
|
||||
|
||||
authentik is maintained by people with limited time and attention.
|
||||
|
||||
Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines, too). It is a boundary point at which people interact with each other and the work done. It is disrespectful and unscalable to approach this boundary with low-effort, unqualified work, since it puts the burden of validation on the maintainer.
|
||||
|
||||
**Our reason for the strict AI policy is not due to an anti-AI stance**, but instead due to the number of highly unqualified AI-driven contributions. A small minority misusing these tools creates an unscalable problem for maintainers.
|
||||
|
||||
In a perfect world, AI would produce high-quality, accurate work every time. But today, that reality depends on careful consideration and usage by the human driver of the AI. Since AI is instead best as _producing code quickly_, we have to have strict rules in place to protect the project and maintainers' limited time.
|
||||
61
CODEOWNERS
61
CODEOWNERS
@@ -1,15 +1,15 @@
|
||||
# Fallback
|
||||
* @goauthentik/backend @goauthentik/frontend
|
||||
# Backend
|
||||
authentik/ @goauthentik/backend
|
||||
blueprints/ @goauthentik/backend
|
||||
src/ @goauthentik/backend
|
||||
cmd/ @goauthentik/backend
|
||||
internal/ @goauthentik/backend
|
||||
lifecycle/ @goauthentik/backend
|
||||
schemas/ @goauthentik/backend
|
||||
scripts/ @goauthentik/backend
|
||||
tests/ @goauthentik/backend
|
||||
/authentik/ @goauthentik/backend
|
||||
/blueprints/ @goauthentik/backend
|
||||
/src/ @goauthentik/backend
|
||||
/cmd/ @goauthentik/backend
|
||||
/internal/ @goauthentik/backend
|
||||
/lifecycle/ @goauthentik/backend
|
||||
/schemas/ @goauthentik/backend
|
||||
/scripts/ @goauthentik/backend
|
||||
/tests/ @goauthentik/backend
|
||||
pyproject.toml @goauthentik/backend
|
||||
uv.lock @goauthentik/backend
|
||||
Cargo.toml @goauthentik/backend
|
||||
@@ -21,41 +21,44 @@ go.sum @goauthentik/backend
|
||||
rust-toolchain.toml @goauthentik/backend
|
||||
# Infrastructure
|
||||
.github/ @goauthentik/infrastructure
|
||||
lifecycle/aws/ @goauthentik/infrastructure
|
||||
lifecycle/container/ @goauthentik/infrastructure
|
||||
/lifecycle/aws/ @goauthentik/infrastructure
|
||||
/lifecycle/container/ @goauthentik/infrastructure
|
||||
.dockerignore @goauthentik/infrastructure
|
||||
Makefile @goauthentik/infrastructure
|
||||
.editorconfig @goauthentik/infrastructure
|
||||
CODEOWNERS @goauthentik/infrastructure
|
||||
# Backend packages
|
||||
packages/ak-* @goauthentik/backend
|
||||
packages/client-rust @goauthentik/backend
|
||||
packages/django-channels-postgres @goauthentik/backend
|
||||
packages/django-postgres-cache @goauthentik/backend
|
||||
packages/django-dramatiq-postgres @goauthentik/backend
|
||||
/packages/ak-* @goauthentik/backend
|
||||
/packages/client-rust @goauthentik/backend
|
||||
/packages/django-channels-postgres @goauthentik/backend
|
||||
/packages/django-postgres-cache @goauthentik/backend
|
||||
/packages/django-dramatiq-postgres @goauthentik/backend
|
||||
# Web packages
|
||||
.npmrc @goauthentik/frontend
|
||||
.nvmrc @goauthentik/frontend
|
||||
tsconfig.json @goauthentik/frontend
|
||||
package.json @goauthentik/frontend
|
||||
package-lock.json @goauthentik/frontend
|
||||
packages/package.json @goauthentik/frontend
|
||||
packages/package-lock.json @goauthentik/frontend
|
||||
packages/client-ts @goauthentik/frontend
|
||||
packages/docusaurus-config @goauthentik/frontend
|
||||
packages/esbuild-plugin-live-reload @goauthentik/frontend
|
||||
packages/eslint-config @goauthentik/frontend
|
||||
packages/prettier-config @goauthentik/frontend
|
||||
packages/logger-js @goauthentik/frontend
|
||||
packages/tsconfig @goauthentik/frontend
|
||||
pnpm-lock.yaml @goauthentik/frontend
|
||||
pnpm-workspace.yaml @goauthentik/frontend
|
||||
/packages/client-ts @goauthentik/frontend
|
||||
/packages/docusaurus-config @goauthentik/frontend
|
||||
/packages/esbuild-plugin-live-reload @goauthentik/frontend
|
||||
/packages/eslint-config @goauthentik/frontend
|
||||
/packages/fonts @goauthentik/frontend
|
||||
/packages/prettier-config @goauthentik/frontend
|
||||
/packages/logger-js @goauthentik/frontend
|
||||
/packages/theme @goauthentik/frontend
|
||||
/packages/tsconfig @goauthentik/frontend
|
||||
# Web
|
||||
web/ @goauthentik/frontend
|
||||
/web/ @goauthentik/frontend
|
||||
# Locale
|
||||
/locale/ @goauthentik/backend @goauthentik/frontend
|
||||
/locale/*/dictionaries @goauthentik/frontend @goauthentik/docs
|
||||
web/xliff/ @goauthentik/backend @goauthentik/frontend
|
||||
/web/xliff/ @goauthentik/backend @goauthentik/frontend
|
||||
# Docs
|
||||
website/ @goauthentik/docs
|
||||
/website/ @goauthentik/docs
|
||||
CODE_OF_CONDUCT.md @goauthentik/docs
|
||||
# Security
|
||||
SECURITY.md @goauthentik/security @goauthentik/docs
|
||||
website/security/ @goauthentik/security @goauthentik/docs
|
||||
/website/security/ @goauthentik/security @goauthentik/docs
|
||||
|
||||
1503
Cargo.lock
generated
1503
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
161
Cargo.toml
161
Cargo.toml
@@ -8,7 +8,7 @@ members = [
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "2026.8.0-rc1"
|
||||
version = "2026.11.0-rc1"
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
description = "Making authentication simple."
|
||||
edition = "2024"
|
||||
@@ -19,13 +19,19 @@ license-file = "LICENSE"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
arc-swap = "= 1.9.1"
|
||||
arc-swap = "= 1.9.2"
|
||||
argh = "= 0.1.19"
|
||||
askama = "= 0.16.0"
|
||||
axum-server = { version = "= 0.8.0", features = ["tls-rustls-no-provider"] }
|
||||
aws-lc-rs = { version = "= 1.17.0", features = ["fips"] }
|
||||
aws-lc-rs = { version = "= 1.18.0", features = ["fips"] }
|
||||
axum = { version = "= 0.8.9", features = ["http2", "macros", "ws"] }
|
||||
clap = { version = "= 4.6.1", features = ["derive", "env"] }
|
||||
client-ip = { version = "0.2.1", features = ["forwarded-header"] }
|
||||
axum-extra = { version = "= 0.12.6", default-features = false, features = [
|
||||
"cookie-signed",
|
||||
"cookie-key-expansion",
|
||||
] }
|
||||
base64 = "= 0.23.1"
|
||||
clap = { version = "= 4.6.6", features = ["derive", "env"] }
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
color-eyre = "= 0.6.5"
|
||||
colored = "= 3.1.1"
|
||||
config-rs = { package = "config", version = "= 0.15.22", default-features = false, features = [
|
||||
@@ -35,22 +41,46 @@ config-rs = { package = "config", version = "= 0.15.22", default-features = fals
|
||||
console-subscriber = "= 0.5.0"
|
||||
dotenvy = "= 0.15.7"
|
||||
durstr = "= 0.5.1"
|
||||
eyre = "= 0.6.12"
|
||||
forwarded-header-value = "= 0.1.1"
|
||||
futures = "= 0.3.32"
|
||||
glob = "= 0.3.3"
|
||||
eyre = "= 0.6.14"
|
||||
futures = "= 0.3.34"
|
||||
glob = "= 0.3.4"
|
||||
http-body-util = "= 0.1.5"
|
||||
hyper = { version = "= 1.11.0", features = ["client", "http1", "http2"] }
|
||||
hyper-rustls = { version = "= 0.27.9", default-features = false, features = [
|
||||
"aws-lc-rs",
|
||||
"http1",
|
||||
"http2",
|
||||
"native-tokio",
|
||||
"tls12",
|
||||
] }
|
||||
hyper-unix-socket = "= 0.6.1"
|
||||
hyper-util = "= 0.1.20"
|
||||
ipnet = { version = "= 2.12.0", features = ["serde"] }
|
||||
json-subscriber = "= 0.2.8"
|
||||
hyper-util = { version = "= 0.1.20", features = [
|
||||
"client-legacy",
|
||||
"http1",
|
||||
"http2",
|
||||
"tokio",
|
||||
] }
|
||||
ipnet = { version = "= 2.12.1", features = ["serde"] }
|
||||
json-subscriber = "= 0.3.0"
|
||||
jsonwebtoken = { version = "= 11.0.0", features = ["aws_lc_rs"] }
|
||||
metrics = "= 0.24.6"
|
||||
metrics-exporter-prometheus = { version = "= 0.18.3", default-features = false }
|
||||
moka = { version = "= 0.12.16", default-features = false, features = [
|
||||
"future",
|
||||
] }
|
||||
nix = { version = "= 0.31.3", features = ["hostname", "signal"] }
|
||||
notify = "= 8.2.0"
|
||||
pem = "= 4.0.0"
|
||||
percent-encoding = "= 2.3.2"
|
||||
pin-project-lite = "= 0.2.17"
|
||||
pyo3 = "= 0.28.3"
|
||||
pyo3-build-config = "= 0.28.3"
|
||||
regex = "= 1.12.3"
|
||||
pyo3 = "= 0.29.0"
|
||||
pyo3-build-config = "= 0.29.0"
|
||||
rand = "= 0.10.2"
|
||||
rcgen = { version = "= 0.14.9", default-features = false, features = [
|
||||
"aws_lc_rs",
|
||||
"fips",
|
||||
] }
|
||||
regex = "= 1.13.1"
|
||||
reqwest = { version = "= 0.13.4", features = [
|
||||
"form",
|
||||
"json",
|
||||
@@ -66,8 +96,8 @@ reqwest-middleware = { version = "= 0.5.2", features = [
|
||||
"query",
|
||||
"rustls",
|
||||
] }
|
||||
rustls = { version = "= 0.23.40", features = ["fips"] }
|
||||
sentry = { version = "= 0.48.2", default-features = false, features = [
|
||||
rustls = { version = "= 0.23.43", features = ["fips"] }
|
||||
sentry = { version = "= 0.49.1", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
@@ -77,13 +107,13 @@ sentry = { version = "= 0.48.2", default-features = false, features = [
|
||||
"tower",
|
||||
"tracing",
|
||||
] }
|
||||
serde = { version = "= 1.0.228", features = ["derive"] }
|
||||
serde_json = "= 1.0.150"
|
||||
serde_repr = "= 0.1.20"
|
||||
serde_with = { version = "= 3.20.0", default-features = false, features = [
|
||||
serde = { version = "= 1.0.229", features = ["derive"] }
|
||||
serde_json = "= 1.0.151"
|
||||
serde_repr = "= 0.1.21"
|
||||
serde_with = { version = "= 3.22.0", default-features = false, features = [
|
||||
"base64",
|
||||
] }
|
||||
sqlx = { version = "= 0.8.6", default-features = false, features = [
|
||||
sqlx = { version = "= 0.9.0", default-features = false, features = [
|
||||
"runtime-tokio",
|
||||
"tls-rustls-aws-lc-rs",
|
||||
"postgres",
|
||||
@@ -95,14 +125,23 @@ sqlx = { version = "= 0.8.6", default-features = false, features = [
|
||||
"json",
|
||||
] }
|
||||
tempfile = "= 3.27.0"
|
||||
thiserror = "= 2.0.18"
|
||||
time = { version = "= 0.3.47", features = ["macros"] }
|
||||
tokio = { version = "= 1.52.3", features = ["full", "tracing"] }
|
||||
thiserror = "= 2.0.20"
|
||||
time = { version = "= 0.3.55", features = ["macros"] }
|
||||
tokio = { version = "= 1.53.1", features = ["full", "tracing"] }
|
||||
tokio-retry2 = "= 0.9.1"
|
||||
tokio-rustls = "= 0.26.4"
|
||||
tokio-util = { version = "= 0.7.18", features = ["full"] }
|
||||
tokio-tungstenite = { version = "= 0.30.0", features = [
|
||||
"rustls-tls-webpki-roots",
|
||||
"url",
|
||||
] }
|
||||
tokio-util = { version = "= 0.7.19", features = ["full"] }
|
||||
tower = "= 0.5.3"
|
||||
tower-http = { version = "= 0.6.11", features = ["timeout"] }
|
||||
tower-http = { version = "= 0.7.0", features = [
|
||||
"compression-full",
|
||||
"fs",
|
||||
"limit",
|
||||
"timeout",
|
||||
] }
|
||||
tracing = "= 0.1.44"
|
||||
tracing-error = "= 0.2.1"
|
||||
tracing-subscriber = { version = "= 0.3.23", features = [
|
||||
@@ -112,12 +151,11 @@ tracing-subscriber = { version = "= 0.3.23", features = [
|
||||
"tracing-log",
|
||||
] }
|
||||
url = "= 2.5.8"
|
||||
uuid = { version = "= 1.23.2", features = ["serde", "v4"] }
|
||||
which = "= 8.0.2"
|
||||
uuid = { version = "= 1.24.1", features = ["serde", "v4"] }
|
||||
|
||||
ak-axum = { package = "authentik-axum", version = "2026.8.0-rc1", path = "./packages/ak-axum" }
|
||||
ak-client = { package = "authentik-client", version = "2026.8.0-rc1", path = "./packages/client-rust" }
|
||||
ak-common = { package = "authentik-common", version = "2026.8.0-rc1", path = "./packages/ak-common", default-features = false }
|
||||
ak-axum = { package = "authentik-axum", version = "2026.11.0-rc1", path = "./packages/ak-axum" }
|
||||
ak-client = { package = "authentik-client", version = "2026.11.0-rc1", path = "./packages/client-rust" }
|
||||
ak-common = { package = "authentik-common", version = "2026.11.0-rc1", path = "./packages/ak-common", default-features = false }
|
||||
|
||||
[workspace.lints.rust]
|
||||
ambiguous_negative_literals = "warn"
|
||||
@@ -236,14 +274,8 @@ verbose_file_reads = "warn"
|
||||
[profile.dev.package.backtrace]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev]
|
||||
panic = "abort"
|
||||
|
||||
[profile.release]
|
||||
debug = 2
|
||||
lto = "fat"
|
||||
# Because of the async runtime, we want to die straightaway if we panic.
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[package]
|
||||
@@ -259,31 +291,76 @@ publish.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["core", "proxy"]
|
||||
core = ["ak-common/core", "dep:pyo3", "dep:sqlx"]
|
||||
proxy = ["ak-common/proxy"]
|
||||
core = ["proxy", "ak-common/core", "dep:pyo3", "dep:sqlx"]
|
||||
proxy = [
|
||||
"ak-common/proxy",
|
||||
"dep:ak-client",
|
||||
"dep:jsonwebtoken",
|
||||
"dep:axum-extra",
|
||||
"dep:aws-lc-rs",
|
||||
"dep:reqwest-middleware",
|
||||
"dep:moka",
|
||||
"dep:base64",
|
||||
"dep:regex",
|
||||
"dep:hyper",
|
||||
"dep:hyper-rustls",
|
||||
"dep:thiserror",
|
||||
"dep:askama",
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
pyo3-build-config.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ak-axum.workspace = true
|
||||
ak-client = { workspace = true, optional = true }
|
||||
ak-common.workspace = true
|
||||
arc-swap.workspace = true
|
||||
argh.workspace = true
|
||||
askama = { workspace = true, optional = true }
|
||||
aws-lc-rs = { workspace = true, optional = true }
|
||||
base64 = { workspace = true, optional = true }
|
||||
axum-extra = { workspace = true, optional = true }
|
||||
axum-server.workspace = true
|
||||
axum.workspace = true
|
||||
color-eyre.workspace = true
|
||||
eyre.workspace = true
|
||||
futures.workspace = true
|
||||
http-body-util.workspace = true
|
||||
hyper = { workspace = true, optional = true }
|
||||
hyper-rustls = { workspace = true, optional = true }
|
||||
hyper-unix-socket.workspace = true
|
||||
hyper-util.workspace = true
|
||||
metrics.workspace = true
|
||||
jsonwebtoken = { workspace = true, optional = true }
|
||||
metrics-exporter-prometheus.workspace = true
|
||||
metrics.workspace = true
|
||||
moka = { workspace = true, optional = true }
|
||||
nix.workspace = true
|
||||
pem.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
pyo3 = { workspace = true, optional = true }
|
||||
rand.workspace = true
|
||||
regex = { workspace = true, optional = true }
|
||||
reqwest-middleware = { workspace = true, optional = true }
|
||||
reqwest.workspace = true
|
||||
rustls.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_repr.workspace = true
|
||||
sqlx = { workspace = true, optional = true }
|
||||
thiserror = { workspace = true, optional = true }
|
||||
time.workspace = true
|
||||
tokio-retry2.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
tower.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
which.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
97
Makefile
97
Makefile
@@ -5,12 +5,12 @@ SHELL := /usr/bin/env bash
|
||||
PWD = $(shell pwd)
|
||||
UID = $(shell id -u)
|
||||
GID = $(shell id -g)
|
||||
PY_SOURCES = authentik packages tests scripts lifecycle .github
|
||||
PY_SOURCES = authentik packages tests scripts lifecycle
|
||||
DOCKER_IMAGE ?= "authentik:test"
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
SED_INPLACE = sed -i ''
|
||||
SED_INPLACE = /usr/bin/sed -i ''
|
||||
else
|
||||
SED_INPLACE = sed -i
|
||||
endif
|
||||
@@ -88,6 +88,9 @@ lint-fix: lint-fix-rust ## Lint and automatically fix errors in the python sour
|
||||
lint-spellcheck: ## Reports spelling errors.
|
||||
npm run lint:spellcheck
|
||||
|
||||
lint-catalogs: ## Reports pnpm catalog pins that drifted between the root, web, and website workspaces.
|
||||
node ./scripts/node/lint-catalogs.ts
|
||||
|
||||
lint: ci-lint-bandit ci-lint-mypy ci-lint-cargo-deny ci-lint-cargo-machete ## Lint the python and golang sources
|
||||
golangci-lint run -v
|
||||
|
||||
@@ -107,14 +110,14 @@ migrate: ## Run the Authentik Django server's migrations
|
||||
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
|
||||
|
||||
aws-cfn: node-install
|
||||
corepack npm install --prefix lifecycle/aws
|
||||
$(UV) run corepack npm run aws-cfn --prefix lifecycle/aws
|
||||
pnpm --dir lifecycle/aws install
|
||||
$(UV) run pnpm --dir lifecycle/aws run aws-cfn
|
||||
|
||||
run: ## Run the main authentik server and worker processes
|
||||
$(UV) run ak allinone
|
||||
|
||||
run-watch: ## Run the authentik server and worker, with auto reloading
|
||||
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs,go --no-meta --notify -- $(UV) run ak allinone
|
||||
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak allinone
|
||||
|
||||
core-i18n-extract:
|
||||
$(UV) run ak makemessages \
|
||||
@@ -163,7 +166,7 @@ endif
|
||||
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' ${PWD}/authentik/__init__.py
|
||||
$(SED_INPLACE) "s/version = \"${current_version}\"/version = \"$(version)\"/" ${PWD}/Cargo.toml ${PWD}/Cargo.lock
|
||||
$(MAKE) gen-build gen-compose aws-cfn
|
||||
$(SED_INPLACE) "s/\"${current_version}\"/\"$(version)\"/" ${PWD}/package.json ${PWD}/package-lock.json ${PWD}/web/package.json ${PWD}/web/package-lock.json
|
||||
$(SED_INPLACE) "s/\"${current_version}\"/\"$(version)\"/" ${PWD}/package.json ${PWD}/web/package.json
|
||||
echo -n $(version) > ${PWD}/internal/constants/VERSION
|
||||
|
||||
#########################
|
||||
@@ -181,11 +184,11 @@ gen-compose:
|
||||
|
||||
gen-changelog: ## (Release) generate the changelog based from the commits since the last version
|
||||
# These are best-effort guesses based on commit messages
|
||||
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
|
||||
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc[0-9]+$$' | tail -1))
|
||||
$(eval current_commit := $(shell git rev-parse HEAD))
|
||||
git log --pretty=format:"- %s" $(shell git merge-base ${last_version} ${current_commit})...${current_commit} > merged_to_current
|
||||
git log --pretty=format:"- %s" $(shell git merge-base ${last_version} ${current_commit})...${last_version} > merged_to_last
|
||||
grep -Eo 'cherry-pick (#\d+)' merged_to_last | cut -d ' ' -f 2 | sed 's/.*/(&)$$/' > cherry_picked_to_last
|
||||
{ grep -Eo 'cherry-pick (#[0-9]+)' merged_to_last || true; } | cut -d ' ' -f 2 | sed 's/.*/(&)$$/' > cherry_picked_to_last
|
||||
grep -vf cherry_picked_to_last merged_to_current | grep -vE '^- (ci:|website)' | sort > changelog.md
|
||||
rm merged_to_current
|
||||
rm merged_to_last
|
||||
@@ -193,7 +196,7 @@ gen-changelog: ## (Release) generate the changelog based from the commits since
|
||||
npx prettier --write changelog.md
|
||||
|
||||
gen-diff: ## (Release) generate the changelog diff between the current schema and the last version
|
||||
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc\d+$$' | tail -1))
|
||||
$(eval last_version := $(shell git tag --list 'version/*' --sort 'version:refname' | grep -vE 'rc[0-9]+$$' | tail -1))
|
||||
git show ${last_version}:schema.yml > schema-old.yml
|
||||
docker compose -f scripts/compose.yml run --rm --user "${UID}:${GID}" diff \
|
||||
--markdown \
|
||||
@@ -214,7 +217,7 @@ gen-client-rust: ## Build and install the authentik API for Rust
|
||||
|
||||
gen-client-ts: ## Build and install the authentik API for Typescript into the authentik UI Application
|
||||
make -C "${PWD}/packages/client-ts" build
|
||||
npm --prefix web install
|
||||
pnpm --dir web install
|
||||
|
||||
_gen-clients: gen-client-go gen-client-rust gen-client-ts
|
||||
gen-clients: ## Build and install API clients used by authentik
|
||||
@@ -229,55 +232,48 @@ gen-dev-config: ## Generate a local development config file
|
||||
## Node.js
|
||||
#########################
|
||||
|
||||
# Packages whose install/postinstall scripts are required for correct
|
||||
# operation (binary downloads, native bindings). The root .npmrc sets
|
||||
# `ignore-scripts=true` to block dependency lifecycle scripts by default;
|
||||
# this list is rebuilt explicitly with scripts re-enabled. Audit any
|
||||
# additions: each entry runs arbitrary code at install time.
|
||||
TRUSTED_INSTALL_SCRIPTS := esbuild chromedriver tree-sitter tree-sitter-json
|
||||
# Lifecycle scripts are blocked by default in pnpm 10+ via
|
||||
# `pnpm-workspace.yaml#onlyBuiltDependencies`. Adding a package to that list
|
||||
# grants it arbitrary code execution at install — audit at review time.
|
||||
|
||||
node-preinstall: ## Install corepack and lint the runtime to ensure the correct Node.js version is being used before installing dependencies.
|
||||
node ./scripts/node/setup-corepack.mjs
|
||||
node-preinstall: ## Verify the active Node.js and pnpm versions match what's in package.json.
|
||||
node ./scripts/node/lint-runtime.mjs
|
||||
|
||||
node-install: node-preinstall ## Install the necessary libraries to build Node.js packages
|
||||
corepack npm ci
|
||||
node-install: node-preinstall ## Install the necessary libraries to build Node.js packages
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
#########################
|
||||
## Web
|
||||
#########################
|
||||
|
||||
web-install: ## Install the necessary libraries to build the Authentik UI
|
||||
corepack npm ci --prefix web
|
||||
|
||||
web-postinstall: ## Trigger postinstall scripts for packages with native bindings or binary downloads, which are blocked by default for security reasons.
|
||||
corepack npm rebuild --prefix web --ignore-scripts=false --foreground-scripts $(TRUSTED_INSTALL_SCRIPTS)
|
||||
web-install: ## Install the necessary libraries to build the Authentik UI
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
|
||||
web-build: node-install ## Build the Authentik UI
|
||||
corepack npm run --prefix web build
|
||||
pnpm --dir web run build
|
||||
|
||||
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
|
||||
|
||||
web-test: ## Run tests for the Authentik UI
|
||||
corepack npm run --prefix web test
|
||||
web-test: ## Run tests for the Authentik UI
|
||||
pnpm --dir web run test
|
||||
|
||||
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
|
||||
corepack npm run --prefix web watch
|
||||
pnpm --dir web run watch
|
||||
web-storybook-watch: ## Build and run the storybook documentation server
|
||||
corepack npm run --prefix web storybook
|
||||
pnpm --dir web run storybook
|
||||
|
||||
web-lint-fix:
|
||||
corepack npm run --prefix web prettier
|
||||
pnpm --dir web run prettier
|
||||
|
||||
web-lint:
|
||||
corepack npm run --prefix web lint
|
||||
corepack npm run --prefix web lit-analyse
|
||||
pnpm --dir web run lint
|
||||
pnpm --dir web run lit-analyse
|
||||
|
||||
web-check-compile:
|
||||
corepack npm run --prefix web tsc
|
||||
pnpm --dir web run tsc
|
||||
|
||||
web-i18n-extract:
|
||||
corepack npm run --prefix web extract-locales
|
||||
pnpm --dir web run extract-locales
|
||||
|
||||
#########################
|
||||
## Docs
|
||||
@@ -286,35 +282,35 @@ web-i18n-extract:
|
||||
docs: docs-lint-fix docs-build ## Automatically fix formatting issues in the Authentik docs source code, lint the code, and compile it
|
||||
|
||||
docs-install: node-install ## Install the necessary libraries to build the Authentik documentation
|
||||
corepack npm ci --prefix website
|
||||
pnpm --dir website install --frozen-lockfile
|
||||
|
||||
docs-lint-fix: lint-spellcheck
|
||||
corepack npm run --prefix website prettier
|
||||
pnpm --dir website run prettier
|
||||
|
||||
docs-build:
|
||||
node ./scripts/node/lint-runtime.mjs website
|
||||
corepack npm run --prefix website build
|
||||
pnpm --dir website run build
|
||||
|
||||
docs-watch: ## Build and watch the topics documentation
|
||||
corepack npm run --prefix website start
|
||||
pnpm --dir website run start
|
||||
|
||||
integrations: docs-lint-fix integrations-build ## Fix formatting issues in the integrations source code, lint the code, and compile it
|
||||
integrations: docs-lint-fix integrations-build ## Fix formatting issues in the integrations source code, lint the code, and compile it
|
||||
|
||||
integrations-build:
|
||||
corepack npm run --prefix website -w integrations build
|
||||
pnpm --dir website run build:integrations
|
||||
|
||||
integrations-watch: ## Build and watch the Integrations documentation
|
||||
corepack npm run --prefix website -w integrations start
|
||||
pnpm --dir website/integrations run start
|
||||
|
||||
docs-api-build:
|
||||
corepack npm run --prefix website -w api build
|
||||
pnpm --dir website run build:api
|
||||
|
||||
docs-api-watch: ## Build and watch the API documentation
|
||||
corepack npm run --prefix website -w api generate
|
||||
corepack npm run --prefix website -w api start
|
||||
pnpm --dir website/api run generate
|
||||
pnpm --dir website/api run start
|
||||
|
||||
docs-api-clean: ## Clean generated API documentation
|
||||
corepack npm run --prefix website -w api build:api:clean
|
||||
docs-api-clean: ## Clean generated API documentation
|
||||
pnpm --dir website/api run clean
|
||||
|
||||
#########################
|
||||
## Docker
|
||||
@@ -356,7 +352,7 @@ ci-lint-pending-migrations: ci--meta-debug
|
||||
$(UV) run ak makemigrations --check
|
||||
|
||||
ci-lint-cargo-deny: ci--meta-debug
|
||||
$(CARGO) deny --locked --workspace check --config "${PWD}/.cargo/deny.toml"
|
||||
$(CARGO) deny --config "${PWD}/.cargo/deny.toml" --locked --workspace check
|
||||
|
||||
ci-lint-cargo-machete: ci--meta-debug
|
||||
$(CARGO) machete
|
||||
@@ -365,7 +361,10 @@ ci-lint-rustfmt: ci--meta-debug
|
||||
$(CARGO) +nightly fmt --all --check -- --config-path "${PWD}/.cargo/rustfmt.toml"
|
||||
|
||||
ci-lint-clippy: ci--meta-debug
|
||||
$(CARGO) clippy --workspace -- -D warnings
|
||||
$(CARGO) clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
ci-lint-catalogs: ci--meta-debug
|
||||
node ./scripts/node/lint-catalogs.ts
|
||||
|
||||
ci-test: ci--meta-debug
|
||||
$(UV) run coverage run manage.py test --keepdb --parallel auto authentik
|
||||
|
||||
19
SECURITY.md
19
SECURITY.md
@@ -1,4 +1,4 @@
|
||||
authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce it's existence and release the fixed version.
|
||||
authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce its existence and release the fixed version.
|
||||
|
||||
## Independent audits and pentests
|
||||
|
||||
@@ -6,7 +6,7 @@ We are committed to engaging in regular pentesting and security audits of authen
|
||||
|
||||
## What authentik classifies as a CVE
|
||||
|
||||
CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is a either vulnerability or exposure. Per NIST, A vulnerability is:
|
||||
CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is either a vulnerability or exposure. Per NIST, A vulnerability is:
|
||||
|
||||
“Weakness in an information system, system security procedures, internal controls, or implementation that could be exploited or triggered by a threat source.”
|
||||
|
||||
@@ -20,15 +20,12 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
||||
|
||||
| Version | Supported |
|
||||
| --------- | --------- |
|
||||
| 2025.2.x | ✅ |
|
||||
| 2026.5.x | ✅ |
|
||||
| 2026.8.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a potential vulnerability, please report it responsibly through one of the following channels:
|
||||
|
||||
- **Email**: [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
- **GitHub**: Submit a private security advisory via our [repository’s advisory portal](https://github.com/goauthentik/authentik/security/advisories/new)
|
||||
If you discover a potential vulnerability, please report it responsibly by submitting a private security advisory via our [repository’s advisory portal](https://github.com/goauthentik/authentik/security/advisories/new).
|
||||
|
||||
When submitting a report, please include as much detail as possible, such as:
|
||||
|
||||
@@ -96,7 +93,7 @@ The destinations of outgoing network requests (HTTP, TCP, etc.) made by authenti
|
||||
|
||||
## Disclosure process
|
||||
|
||||
1. Report from Github or Issue is reported via Email as listed above.
|
||||
1. Vulnerability is reported via a GitHub Security Advisory, as listed above.
|
||||
2. The authentik Security team will try to reproduce the issue and ask for more information if required.
|
||||
3. A severity level is assigned.
|
||||
4. A fix is created, and if possible tested by the issue reporter.
|
||||
@@ -107,3 +104,9 @@ The destinations of outgoing network requests (HTTP, TCP, etc.) made by authenti
|
||||
## Getting security notifications
|
||||
|
||||
To get security notifications, subscribe to the mailing list [here](https://groups.google.com/g/authentik-security-announcements) or join the [discord](https://goauthentik.io/discord) server.
|
||||
|
||||
## Contact
|
||||
|
||||
For general inquiries, you can reach the authentik Security team at [security@goauthentik.io](mailto:security@goauthentik.io).
|
||||
|
||||
_Please do not use email for vulnerability reports, instead use our [repository’s advisory portal](https://github.com/goauthentik/authentik/security/advisories/new)._
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2026.8.0-rc1"
|
||||
VERSION = "2026.11.0-rc1"
|
||||
VERSION_FAMILY_PREVIOUS = "2026.8"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
@@ -12,6 +13,11 @@ def authentik_version() -> str:
|
||||
return VERSION
|
||||
|
||||
|
||||
@lru_cache
|
||||
def authentik_version_family_previous() -> str:
|
||||
return VERSION_FAMILY_PREVIOUS
|
||||
|
||||
|
||||
@lru_cache
|
||||
def authentik_build_hash(fallback: str | None = None) -> str:
|
||||
"""Get build hash"""
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""Meta API"""
|
||||
|
||||
from django.apps import apps
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import AttributesMixin
|
||||
from authentik.lib.api import Models
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
|
||||
@@ -36,12 +39,19 @@ class AppsViewSet(ViewSet):
|
||||
class ModelViewSet(ViewSet):
|
||||
"""Read-only view list all installed models"""
|
||||
|
||||
class ModelFilterSerializer(PassiveSerializer):
|
||||
filter_has_attributes = BooleanField(allow_null=True, default=None)
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses={200: AppSerializer(many=True)})
|
||||
def list(self, request: Request) -> Response:
|
||||
@extend_schema(responses={200: AppSerializer(many=True)}, parameters=[ModelFilterSerializer])
|
||||
@validate(ModelFilterSerializer, "query")
|
||||
def list(self, request: Request, query: ModelFilterSerializer) -> Response:
|
||||
"""Read-only view list all installed models"""
|
||||
data = []
|
||||
for name, label in Models.choices:
|
||||
if query.validated_data["filter_has_attributes"]:
|
||||
if not issubclass(apps.get_model(name), AttributesMixin):
|
||||
continue
|
||||
data.append({"name": name, "label": label})
|
||||
return Response(AppSerializer(data, many=True).data)
|
||||
|
||||
@@ -23,6 +23,7 @@ from authentik.lib.utils.reflection import get_env
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
from authentik.outposts.models import Outpost
|
||||
from authentik.rbac.permissions import HasPermission
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
def fips_enabled():
|
||||
@@ -58,6 +59,7 @@ class SystemInfoSerializer(PassiveSerializer):
|
||||
server_time = SerializerMethodField()
|
||||
embedded_outpost_disabled = SerializerMethodField()
|
||||
embedded_outpost_host = SerializerMethodField()
|
||||
base_url = SerializerMethodField()
|
||||
|
||||
def get_http_headers(self, request: Request) -> dict[str, str]:
|
||||
"""Get HTTP Request headers"""
|
||||
@@ -114,6 +116,10 @@ class SystemInfoSerializer(PassiveSerializer):
|
||||
return ""
|
||||
return outposts.first().config.authentik_host
|
||||
|
||||
def get_base_url(self, request: Request) -> str:
|
||||
"""Configured external base URL. Can be empty"""
|
||||
return get_current_tenant().base_url
|
||||
|
||||
|
||||
class SystemView(APIView):
|
||||
"""Get system information."""
|
||||
|
||||
@@ -92,6 +92,7 @@ class FileBackend(ManageableBackend):
|
||||
"nbf": now() - timedelta(seconds=15),
|
||||
},
|
||||
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
|
||||
# Must match src/server/static.rs
|
||||
algorithm="HS256",
|
||||
)
|
||||
url = f"{prefix}/files/{path}?token={token}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections.abc import Generator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from tempfile import SpooledTemporaryFile
|
||||
from typing import Any, TypeVar
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import boto3
|
||||
@@ -14,6 +15,8 @@ from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
|
||||
_ConfigValue = TypeVar("_ConfigValue")
|
||||
|
||||
|
||||
class S3Backend(ManageableBackend):
|
||||
"""S3-compatible object storage backend.
|
||||
@@ -32,18 +35,29 @@ class S3Backend(ManageableBackend):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._config = {}
|
||||
self._session = None
|
||||
self._client = None
|
||||
|
||||
def _get_config(self, key: str, default: str | None) -> tuple[str | None, bool]:
|
||||
def _remember_config(self, key: str, refreshed: _ConfigValue) -> tuple[_ConfigValue, bool]:
|
||||
unset = object()
|
||||
current = self._config.get(key, unset)
|
||||
if current is unset:
|
||||
current = refreshed
|
||||
self._config[key] = refreshed
|
||||
return refreshed, current != refreshed
|
||||
|
||||
def _get_config(self, key: str, default: Any) -> tuple[Any, bool]:
|
||||
refreshed = CONFIG.refresh(
|
||||
f"storage.{self.usage.value}.{self.name}.{key}",
|
||||
CONFIG.refresh(f"storage.{self.name}.{key}", default),
|
||||
)
|
||||
if current is unset:
|
||||
current = refreshed
|
||||
self._config[key] = refreshed
|
||||
return (refreshed, current != refreshed)
|
||||
return self._remember_config(key, refreshed)
|
||||
|
||||
def _get_bool_config(self, key: str, default: bool) -> tuple[bool, bool]:
|
||||
refreshed = CONFIG.get_bool(
|
||||
f"storage.{self.usage.value}.{self.name}.{key}",
|
||||
CONFIG.get_bool(f"storage.{self.name}.{key}", default),
|
||||
)
|
||||
return self._remember_config(key, refreshed)
|
||||
|
||||
@property
|
||||
def base_path(self) -> str:
|
||||
@@ -64,6 +78,7 @@ class S3Backend(ManageableBackend):
|
||||
if session_profile is not None:
|
||||
if session_profile_r or self._session is None:
|
||||
self._session = boto3.Session(profile_name=session_profile)
|
||||
self._client = None
|
||||
return self._session
|
||||
else:
|
||||
return self._session
|
||||
@@ -77,6 +92,7 @@ class S3Backend(ManageableBackend):
|
||||
aws_secret_access_key=secret_key,
|
||||
aws_session_token=session_token,
|
||||
)
|
||||
self._client = None
|
||||
return self._session
|
||||
else:
|
||||
return self._session
|
||||
@@ -84,26 +100,23 @@ class S3Backend(ManageableBackend):
|
||||
@property
|
||||
def client(self):
|
||||
"""Create S3 client with configured endpoint and region."""
|
||||
endpoint_url = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.endpoint",
|
||||
CONFIG.get(f"storage.{self.name}.endpoint", None),
|
||||
)
|
||||
use_ssl = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.use_ssl",
|
||||
CONFIG.get(f"storage.{self.name}.use_ssl", True),
|
||||
)
|
||||
region_name = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.region",
|
||||
CONFIG.get(f"storage.{self.name}.region", None),
|
||||
)
|
||||
addressing_style = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.addressing_style",
|
||||
CONFIG.get(f"storage.{self.name}.addressing_style", "auto"),
|
||||
)
|
||||
signature_version = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.signature_version",
|
||||
CONFIG.get(f"storage.{self.name}.signature_version", "s3v4"),
|
||||
)
|
||||
endpoint_url, endpoint_url_r = self._get_config("endpoint", None)
|
||||
session = self.session
|
||||
use_ssl, use_ssl_r = self._get_bool_config("use_ssl", True)
|
||||
region_name, region_name_r = self._get_config("region", None)
|
||||
addressing_style, addressing_style_r = self._get_config("addressing_style", "auto")
|
||||
signature_version, signature_version_r = self._get_config("signature_version", "s3v4")
|
||||
|
||||
if self._client is not None and not any(
|
||||
(
|
||||
endpoint_url_r,
|
||||
use_ssl_r,
|
||||
region_name_r,
|
||||
addressing_style_r,
|
||||
signature_version_r,
|
||||
)
|
||||
):
|
||||
return self._client
|
||||
# Keep signature_version pass-through and let boto3/botocore handle it.
|
||||
# In boto3's S3 configuration docs, `s3v4` (default) and deprecated `s3`
|
||||
# are the documented values:
|
||||
@@ -111,7 +124,7 @@ class S3Backend(ManageableBackend):
|
||||
# Botocore also supports additional signer names, so we intentionally do
|
||||
# not enforce a restricted allowlist here.
|
||||
|
||||
return self.session.client(
|
||||
self._client = session.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint_url,
|
||||
use_ssl=use_ssl,
|
||||
@@ -120,6 +133,7 @@ class S3Backend(ManageableBackend):
|
||||
signature_version=signature_version, s3={"addressing_style": addressing_style}
|
||||
),
|
||||
)
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
|
||||
@@ -1,14 +1,55 @@
|
||||
from unittest import skipUnless
|
||||
from unittest.mock import Mock, patch
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from botocore.exceptions import UnsupportedSignatureVersionError
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.backends.s3 import S3Backend
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestS3BackendClientCache(TestCase):
|
||||
"""Test S3 client caching."""
|
||||
|
||||
@CONFIG.patch("storage.s3.access_key", "accessKey1")
|
||||
@CONFIG.patch("storage.s3.secret_key", "secretKey1")
|
||||
@CONFIG.patch("storage.s3.use_ssl", "true")
|
||||
def test_client_reuses_boto_client(self):
|
||||
"""Test repeated client access reuses the same boto client."""
|
||||
with patch("authentik.admin.files.backends.s3.boto3.Session") as session_cls:
|
||||
session = session_cls.return_value
|
||||
client = Mock()
|
||||
session.client.return_value = client
|
||||
|
||||
backend = S3Backend(FileUsage.MEDIA)
|
||||
|
||||
self.assertIs(backend.client, client)
|
||||
self.assertIs(backend.client, client)
|
||||
session.client.assert_called_once()
|
||||
self.assertIs(session.client.call_args.kwargs["use_ssl"], True)
|
||||
|
||||
@CONFIG.patch("storage.s3.access_key", "accessKey1")
|
||||
@CONFIG.patch("storage.s3.secret_key", "secretKey1")
|
||||
def test_client_refreshes_when_config_changes(self):
|
||||
"""Test client cache is invalidated when S3 client config changes."""
|
||||
with CONFIG.patch("storage.s3.endpoint", "https://s3-1.example.com"):
|
||||
with patch("authentik.admin.files.backends.s3.boto3.Session") as session_cls:
|
||||
session = session_cls.return_value
|
||||
first_client = Mock()
|
||||
second_client = Mock()
|
||||
session.client.side_effect = [first_client, second_client]
|
||||
|
||||
backend = S3Backend(FileUsage.MEDIA)
|
||||
|
||||
self.assertIs(backend.client, first_client)
|
||||
with CONFIG.patch("storage.s3.endpoint", "https://s3-2.example.com"):
|
||||
self.assertIs(backend.client, second_client)
|
||||
self.assertEqual(session.client.call_count, 2)
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
"""Test S3 backend functionality"""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from hmac import compare_digest
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
@@ -18,6 +18,9 @@ from authentik.core.middleware import CTX_AUTH_VIA
|
||||
from authentik.core.models import Token, TokenIntents, User, UserTypes
|
||||
from authentik.outposts.models import Outpost
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
|
||||
LOGGER = get_logger()
|
||||
_tmp = Path(gettempdir())
|
||||
try:
|
||||
@@ -169,6 +172,6 @@ class TokenSchema(OpenApiAuthenticationExtension):
|
||||
target_class = TokenAuthentication
|
||||
name = "authentik"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
def get_security_definition(self, auto_schema: AutoSchema):
|
||||
"""Auth schema"""
|
||||
return {"type": "http", "scheme": "bearer"}
|
||||
|
||||
23
authentik/api/parsers.py
Normal file
23
authentik/api/parsers.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from msgspec import DecodeError
|
||||
from msgspec.json import Decoder
|
||||
from rest_framework.exceptions import ParseError
|
||||
from rest_framework.parsers import BaseParser
|
||||
|
||||
_DECODER = Decoder()
|
||||
|
||||
|
||||
class MsgspecJSONParser(BaseParser):
|
||||
media_type = "application/json"
|
||||
|
||||
def parse(self, stream, media_type=None, parser_context: dict[str, Any] | None = None):
|
||||
parser_context = parser_context or {}
|
||||
encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET)
|
||||
|
||||
try:
|
||||
data = stream.read().decode(encoding)
|
||||
return _DECODER.decode(data)
|
||||
except DecodeError as exc:
|
||||
raise ParseError(f"JSON parse error - {exc}") from exc
|
||||
39
authentik/api/renderers.py
Normal file
39
authentik/api/renderers.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from django.db.models.enums import ChoicesType
|
||||
from django.utils.functional import Promise
|
||||
from msgspec.json import Encoder
|
||||
from rest_framework.renderers import BaseRenderer
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
|
||||
def enc_hook(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return dict(obj)
|
||||
if isinstance(obj, list):
|
||||
return list(obj)
|
||||
if isinstance(obj, (str, UUID, Promise, ChoicesType)):
|
||||
return str(obj)
|
||||
if hasattr(obj, "tolist"):
|
||||
return obj.tolist()
|
||||
if hasattr(obj, "__iter__"):
|
||||
return list(item for item in obj)
|
||||
return None
|
||||
|
||||
|
||||
_ENCODER = Encoder(
|
||||
enc_hook=enc_hook,
|
||||
decimal_format="string" if api_settings.COERCE_DECIMAL_TO_STRING else "number",
|
||||
)
|
||||
|
||||
|
||||
class MsgspecJSONRenderer(BaseRenderer):
|
||||
media_type = "application/json"
|
||||
format = "json"
|
||||
|
||||
def render(self, data: Any, accepted_media_type=None, renderer_context=None) -> bytes:
|
||||
if data is None:
|
||||
return b""
|
||||
|
||||
return _ENCODER.encode(data)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections import OrderedDict, defaultdict
|
||||
from collections.abc import Generator
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import connection
|
||||
from django.db.models import Model, Q
|
||||
@@ -15,6 +16,17 @@ class JSONSearchField(StrField):
|
||||
|
||||
model: Model
|
||||
|
||||
value_types = [str, bool, int, float, Decimal]
|
||||
value_types_description = "strings, booleans or numbers"
|
||||
|
||||
def get_lookup_value(self, value):
|
||||
value = super().get_lookup_value(value)
|
||||
if isinstance(value, list):
|
||||
return [float(item) if isinstance(item, Decimal) else item for item in value]
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model=None,
|
||||
|
||||
@@ -71,3 +71,28 @@ class QLTest(APITestCase):
|
||||
content = loads(res.content)
|
||||
self.assertEqual(content["pagination"]["count"], 1)
|
||||
self.assertEqual(content["results"][0]["username"], self.user.username)
|
||||
|
||||
def test_search_json_non_string(self):
|
||||
"""Test search queries against non-string JSON values"""
|
||||
self.user.attributes = {"enabled": True, "count": 3, "speed": 1.5}
|
||||
self.user.save()
|
||||
self.client.force_login(self.user)
|
||||
for query in (
|
||||
"attributes.enabled = True",
|
||||
"attributes.count = 3",
|
||||
"attributes.speed = 1.5",
|
||||
"attributes.count = 3.0",
|
||||
"attributes.speed in (1.5, 2.5)",
|
||||
"attributes.speed >= 1.0 and attributes.speed <= 2.0",
|
||||
):
|
||||
with self.subTest(query=query):
|
||||
res = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:user-list",
|
||||
)
|
||||
+ f"?{urlencode({"search": query})}"
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
content = loads(res.content)
|
||||
self.assertEqual(content["pagination"]["count"], 1)
|
||||
self.assertEqual(content["results"][0]["username"], self.user.username)
|
||||
|
||||
47
authentik/api/tests/test_throttle.py
Normal file
47
authentik/api/tests/test_throttle.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Tests for ``authentik.api.throttle.LocalAnonRateThrottle``."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import caches
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.api.throttle import LocalAnonRateThrottle
|
||||
|
||||
|
||||
class TestLocalAnonRateThrottle(TestCase):
|
||||
"""The throttle must use the in-process LocMem cache, not the default
|
||||
(PG-backed) cache — otherwise every API request incurs 2 cache ops
|
||||
against PG just to make the throttle decision."""
|
||||
|
||||
def test_throttle_uses_locmem_cache_backend(self):
|
||||
"""The throttle's ``cache`` attribute is the ``throttle`` alias."""
|
||||
throttle = LocalAnonRateThrottle()
|
||||
self.assertIs(throttle.cache, caches["throttle"])
|
||||
|
||||
def test_throttle_cache_alias_is_locmem(self):
|
||||
"""The ``throttle`` cache alias is backed by LocMemCache."""
|
||||
self.assertEqual(
|
||||
settings.CACHES["throttle"]["BACKEND"],
|
||||
"django.core.cache.backends.locmem.LocMemCache",
|
||||
)
|
||||
|
||||
def test_throttle_cache_has_sufficient_max_entries(self):
|
||||
"""``MAX_ENTRIES`` is high enough to avoid LRU-evicting active
|
||||
counters under realistic IP diversity."""
|
||||
max_entries = settings.CACHES["throttle"].get("OPTIONS", {}).get("MAX_ENTRIES", 300)
|
||||
self.assertGreaterEqual(max_entries, 1000)
|
||||
|
||||
def test_throttle_counter_isolated_from_default_cache(self):
|
||||
"""Throttle writes go to the LocMem cache, not the default cache."""
|
||||
throttle = LocalAnonRateThrottle()
|
||||
test_key = "test-throttle-isolation-key"
|
||||
throttle.cache.set(test_key, ["a", "b", "c"], 30)
|
||||
self.assertEqual(throttle.cache.get(test_key), ["a", "b", "c"])
|
||||
self.assertIsNone(caches["default"].get(test_key))
|
||||
throttle.cache.delete(test_key)
|
||||
|
||||
def test_default_throttle_class_is_local_throttle(self):
|
||||
"""REST_FRAMEWORK uses ``LocalAnonRateThrottle``, not DRF's stock one."""
|
||||
self.assertEqual(
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"],
|
||||
["authentik.api.throttle.LocalAnonRateThrottle"],
|
||||
)
|
||||
19
authentik/api/throttle.py
Normal file
19
authentik/api/throttle.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Custom DRF throttle classes for authentik."""
|
||||
|
||||
from django.core.cache import caches
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
|
||||
|
||||
class LocalAnonRateThrottle(AnonRateThrottle):
|
||||
"""Anonymous IP-based rate throttle backed by an in-process cache.
|
||||
|
||||
DRF's stock ``AnonRateThrottle`` uses the default cache. With authentik's
|
||||
PG-backed default cache, every API request issues a ``cache.get`` +
|
||||
``cache.set`` against PG just to make the throttle decision — the
|
||||
protective layer amplifies DB load under flood.
|
||||
|
||||
Points the throttle's cache at the ``throttle`` LocMemCache alias instead.
|
||||
Counters are per-process (per gunicorn worker)
|
||||
"""
|
||||
|
||||
cache = caches["throttle"]
|
||||
@@ -37,6 +37,8 @@ class Capabilities(models.TextChoices):
|
||||
CAN_IMPERSONATE = "can_impersonate"
|
||||
CAN_DEBUG = "can_debug"
|
||||
IS_ENTERPRISE = "is_enterprise"
|
||||
CAN_REQUEST = "can_request"
|
||||
CAN_AGENT_SELF_SERVICE = "can_agent_self_service"
|
||||
|
||||
|
||||
class ErrorReportingConfigSerializer(PassiveSerializer):
|
||||
@@ -82,7 +84,7 @@ class ConfigView(APIView):
|
||||
caps.append(Capabilities.CAN_DEBUG)
|
||||
if "authentik.enterprise" in settings.INSTALLED_APPS:
|
||||
caps.append(Capabilities.IS_ENTERPRISE)
|
||||
for _, result in capabilities.send(sender=ConfigView):
|
||||
for _, result in capabilities.send(sender=ConfigView, request=request):
|
||||
if result:
|
||||
caps.append(result)
|
||||
return caps
|
||||
|
||||
@@ -23,7 +23,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.models import BlueprintInstance
|
||||
from authentik.blueprints.v1.common import Blueprint
|
||||
from authentik.blueprints.v1.common import Blueprint, EntryInvalidError
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.blueprints.v1.oci import OCI_PREFIX
|
||||
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
||||
@@ -236,7 +236,10 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
|
||||
else:
|
||||
raise ValidationError("Either path or file must be set")
|
||||
context = body.validated_data.get("context") or {}
|
||||
importer = Importer.from_string(string_contents, context)
|
||||
try:
|
||||
importer = Importer.from_string(string_contents, context)
|
||||
except EntryInvalidError as exc:
|
||||
raise ValidationError(_("Invalid blueprint file: {exc}".format(exc=str(exc)))) from None
|
||||
|
||||
check_blueprint_perms(importer.blueprint, request.user)
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ class Command(BaseCommand):
|
||||
"""Apply blueprint from commandline"""
|
||||
|
||||
@no_translations
|
||||
def handle(self, *args, **options):
|
||||
def handle(self, *args, blueprints: list[str], dry_run: bool, **options):
|
||||
"""Apply all blueprints in order, abort when one fails to import"""
|
||||
for tenant in Tenant.objects.filter(ready=True):
|
||||
with tenant:
|
||||
for blueprint_path in options.get("blueprints", []):
|
||||
for blueprint_path in blueprints:
|
||||
content = BlueprintInstance(path=blueprint_path).retrieve()
|
||||
importer = Importer.from_string(content)
|
||||
valid, logs = importer.validate()
|
||||
@@ -30,7 +30,11 @@ class Command(BaseCommand):
|
||||
for log in logs:
|
||||
self.stderr.write(f"\t{log.logger}: {log.event}: {log.attributes}")
|
||||
sys_exit(1)
|
||||
importer.apply()
|
||||
if not dry_run:
|
||||
importer.apply()
|
||||
else:
|
||||
LOGGER.info("Dry-run enabled, not applying blueprint")
|
||||
|
||||
def add_arguments(self, parser: ArgumentParser):
|
||||
parser.add_argument("--dry-run", action="store_true", default=False)
|
||||
parser.add_argument("blueprints", nargs="+", type=str)
|
||||
|
||||
@@ -73,6 +73,11 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("authentik_flows", "0001_initial")]
|
||||
|
||||
# migration_blueprint_import below treats "a flow already exists" as "this is an existing
|
||||
# install". That only holds if we run before any migration that creates a flow, which is
|
||||
# otherwise up to how Django happens to order the plan.
|
||||
run_before = [("authentik_core", "0040_provider_invalidation_flow")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BlueprintInstance",
|
||||
|
||||
@@ -21,6 +21,7 @@ entries:
|
||||
provider_type: github
|
||||
consumer_key: !Env foo
|
||||
consumer_secret: !Env [bar, baz]
|
||||
consumer_secret2: !Env [non-existing, !Context foo]
|
||||
authentication_flow:
|
||||
!Find [
|
||||
authentik_flows.Flow,
|
||||
@@ -55,6 +56,10 @@ entries:
|
||||
suffix,
|
||||
]
|
||||
policy_pk2: !Format ["%%s-%%s", !KeyOf policy, suffix]
|
||||
boolEq:
|
||||
!Condition [EQ, "2", "2"]
|
||||
boolNeq:
|
||||
!Condition [NEQ, 2, "2"]
|
||||
boolAnd:
|
||||
!Condition [AND, !Context foo, !Format ["%%s", "a_string"], 1]
|
||||
boolNand:
|
||||
|
||||
88
authentik/blueprints/tests/test_schema.py
Normal file
88
authentik/blueprints/tests/test_schema.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Test blueprint JSON Schema generation"""
|
||||
|
||||
from json import dumps, loads
|
||||
|
||||
from django.test import TestCase
|
||||
from jsonschema import Draft7Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
|
||||
from authentik.blueprints.v1.schema import SchemaBuilder
|
||||
|
||||
|
||||
class TestSchema(TestCase):
|
||||
"""Test blueprint schema generation"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.builder = SchemaBuilder()
|
||||
self.builder.build()
|
||||
# Validate the artifact consumers actually get. `build_schema` dumps with
|
||||
# `json_default` to resolve gettext_lazy proxies into strings, so the
|
||||
# in-memory dict still holds lazy objects that no JSON Schema validator
|
||||
# would accept. Round-tripping here matches the published schema.json.
|
||||
self.schema = loads(dumps(self.builder.schema, default=SchemaBuilder.json_default))
|
||||
|
||||
def test_schema_declares_a_dialect(self):
|
||||
"""The generated schema must say which dialect it is written in"""
|
||||
self.assertIn("$schema", self.schema)
|
||||
|
||||
def test_schema_is_valid_against_its_declared_dialect(self):
|
||||
"""Regression for #24248.
|
||||
|
||||
The schema declared draft-07 but used `$defs`, which is 2020-12
|
||||
vocabulary — draft-07 spells it `definitions`. A validator honouring the
|
||||
declared dialect therefore could not resolve `#/$defs/...` references,
|
||||
so nothing that consumed the published schema.json could validate a
|
||||
blueprint.
|
||||
"""
|
||||
self.assertEqual(self.schema["$schema"], "http://json-schema.org/draft-07/schema")
|
||||
try:
|
||||
Draft7Validator.check_schema(self.schema)
|
||||
except SchemaError as exc: # pragma: no cover - failure path
|
||||
self.fail(f"generated schema is not valid draft-07: {exc}")
|
||||
|
||||
def test_schema_uses_draft_07_definitions_keyword(self):
|
||||
"""`$defs` is 2020-12; under the declared draft-07 dialect it is inert.
|
||||
|
||||
Keeping it would leave every definition unreachable while looking
|
||||
correct in the emitted file.
|
||||
"""
|
||||
self.assertIn("definitions", self.schema)
|
||||
self.assertNotIn("$defs", self.schema)
|
||||
|
||||
def test_every_ref_resolves(self):
|
||||
"""A `$ref` pointing at a keyword the dialect does not define is dead.
|
||||
|
||||
This is what the issue actually reported: refs into `#/$defs/...` that
|
||||
no draft-07 validator could follow.
|
||||
"""
|
||||
refs = self._collect_refs(self.schema)
|
||||
self.assertGreater(len(refs), 0, "expected the schema to contain $refs")
|
||||
for ref in refs:
|
||||
self.assertTrue(
|
||||
ref.startswith("#/definitions/"),
|
||||
f"$ref {ref!r} does not point into #/definitions/",
|
||||
)
|
||||
pointer = ref.removeprefix("#/definitions/")
|
||||
self.assertIn(
|
||||
pointer,
|
||||
self.schema["definitions"],
|
||||
f"$ref {ref!r} does not resolve to a definition",
|
||||
)
|
||||
|
||||
def test_blueprint_entry_definition_is_populated(self):
|
||||
"""The entry oneOf is what blueprints are actually validated against."""
|
||||
self.assertGreater(len(self.schema["definitions"]["blueprint_entry"]["oneOf"]), 0)
|
||||
|
||||
def _collect_refs(self, node) -> list[str]:
|
||||
"""Every `$ref` value anywhere in the schema."""
|
||||
found = []
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "$ref" and isinstance(value, str):
|
||||
found.append(value)
|
||||
else:
|
||||
found.extend(self._collect_refs(value))
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
found.extend(self._collect_refs(item))
|
||||
return found
|
||||
@@ -4,8 +4,10 @@ from os import chmod, environ, unlink, write
|
||||
from tempfile import mkstemp
|
||||
|
||||
from django.test import TransactionTestCase
|
||||
from yaml import load
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.blueprints.v1.common import BlueprintLoader
|
||||
from authentik.blueprints.v1.exporter import FlowExporter
|
||||
from authentik.blueprints.v1.importer import Importer, transaction_rollback
|
||||
from authentik.core.models import Group
|
||||
@@ -38,6 +40,37 @@ class TestBlueprintsV1(TransactionTestCase):
|
||||
)
|
||||
self.assertFalse(importer.validate()[0])
|
||||
|
||||
def test_yaml_tag_repr_does_not_raise(self):
|
||||
"""repr() of a YAML tag must never raise (it is used by log sanitization)."""
|
||||
tag = load("!KeyOf does-not-exist", Loader=BlueprintLoader)
|
||||
self.assertIsInstance(repr(tag), str)
|
||||
|
||||
def test_validate_invalid_entry_holding_yaml_tag(self):
|
||||
"""An invalid entry that still holds a raw !KeyOf must return validation
|
||||
errors instead of raising while sanitizing the logged entry."""
|
||||
importer = Importer.from_string("""
|
||||
version: 1
|
||||
entries:
|
||||
- model: authentik_providers_oauth2.scopemapping
|
||||
id: sm
|
||||
identifiers: { scope_name: test-tag-scope }
|
||||
attrs:
|
||||
name: test-tag-scope
|
||||
scope_name: test-tag-scope
|
||||
expression: "return {}"
|
||||
- model: authentik_providers_oauth2.oauth2provider
|
||||
id: provider
|
||||
identifiers: { client_id: test-tag }
|
||||
attrs:
|
||||
name: test-tag
|
||||
client_id: test-tag
|
||||
property_mappings:
|
||||
- !KeyOf sm
|
||||
""")
|
||||
valid, logs = importer.validate()
|
||||
self.assertFalse(valid)
|
||||
self.assertGreater(len(logs), 0)
|
||||
|
||||
def test_validated_import_dict_identifiers(self):
|
||||
"""Test importing blueprints with dict identifiers."""
|
||||
Group.objects.filter(name__istartswith="test").delete()
|
||||
@@ -157,6 +190,8 @@ class TestBlueprintsV1(TransactionTestCase):
|
||||
{
|
||||
"policy_pk1": str(policy.pk) + "-suffix",
|
||||
"policy_pk2": str(policy.pk) + "-suffix",
|
||||
"boolEq": True,
|
||||
"boolNeq": False,
|
||||
"boolAnd": True,
|
||||
"boolNand": False,
|
||||
"boolOr": True,
|
||||
|
||||
@@ -156,6 +156,40 @@ class TestBlueprintsV1API(APITestCase):
|
||||
self.assertFalse(res.json()["success"])
|
||||
self.assertGreater(len(res.json()["logs"]), 0)
|
||||
|
||||
def test_api_import_invalid_blueprint_with_yaml_tag_returns_result_payload(self):
|
||||
"""An invalid entry that still holds a raw YAML tag (e.g. !KeyOf) must return
|
||||
a result payload, not crash while sanitizing the logged entry."""
|
||||
content = """
|
||||
version: 1
|
||||
entries:
|
||||
- model: authentik_providers_oauth2.scopemapping
|
||||
id: sm
|
||||
identifiers: { scope_name: test-tag-scope }
|
||||
attrs:
|
||||
name: test-tag-scope
|
||||
scope_name: test-tag-scope
|
||||
expression: "return {}"
|
||||
- model: authentik_providers_oauth2.oauth2provider
|
||||
id: provider
|
||||
identifiers: { client_id: test-tag }
|
||||
attrs:
|
||||
name: test-tag
|
||||
client_id: test-tag
|
||||
property_mappings:
|
||||
- !KeyOf sm
|
||||
"""
|
||||
file = SimpleUploadedFile("invalid-blueprint-tag.yaml", content.encode())
|
||||
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:blueprintinstance-import-"),
|
||||
data={"file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertFalse(res.json()["success"])
|
||||
self.assertGreater(len(res.json()["logs"]), 0)
|
||||
|
||||
def test_api_import_unknown_path(self):
|
||||
"""Path not in available blueprints is rejected (covers api.py:56)."""
|
||||
res = self.client.post(
|
||||
|
||||
@@ -208,7 +208,11 @@ class YAMLTag:
|
||||
"""Base class for all YAML Tags"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.resolve(BlueprintEntry(""), Blueprint()))
|
||||
# resolve() may raise; a repr must never raise (called by the log sanitizer).
|
||||
try:
|
||||
return str(self.resolve(BlueprintEntry(""), Blueprint()))
|
||||
except Exception as exc: # noqa: BLE001 - a repr must never raise
|
||||
return f"<{self.__class__.__name__}> (failed to resolve: {exc})"
|
||||
|
||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||
"""Implement yaml tag logic"""
|
||||
@@ -264,7 +268,11 @@ class Env(YAMLTag):
|
||||
self.default = loader.construct_object(node.value[1])
|
||||
|
||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||
return getenv(self.key) or self.default
|
||||
if env := getenv(self.key):
|
||||
return env
|
||||
if isinstance(self.default, YAMLTag):
|
||||
return self.default.resolve(entry, blueprint)
|
||||
return self.default
|
||||
|
||||
|
||||
class File(YAMLTag):
|
||||
@@ -426,12 +434,14 @@ class FindObject(Find):
|
||||
class Condition(YAMLTag):
|
||||
"""Convert all values to a single boolean"""
|
||||
|
||||
mode: Literal["AND", "NAND", "OR", "NOR", "XOR", "XNOR"]
|
||||
mode: Literal["EQ", "NEQ", "AND", "NAND", "OR", "NOR", "XOR", "XNOR"]
|
||||
args: list[Any]
|
||||
|
||||
_COMPARATORS = {
|
||||
# Using all and any here instead of from operator import iand, ior
|
||||
# to improve performance
|
||||
"EQ": lambda args: all(x == args[0] for x in args),
|
||||
"NEQ": lambda args: not all(x == args[0] for x in args),
|
||||
"AND": all,
|
||||
"NAND": lambda args: not all(args),
|
||||
"OR": any,
|
||||
|
||||
@@ -20,6 +20,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
from yaml import load
|
||||
from yaml.error import YAMLError
|
||||
|
||||
from authentik.blueprints.v1.common import (
|
||||
Blueprint,
|
||||
@@ -52,7 +53,7 @@ from authentik.policies.models import Policy, PolicyBindingModel
|
||||
from authentik.rbac.models import Role
|
||||
|
||||
# Context set when the serializer is created in a blueprint context
|
||||
# Update website/docs/customize/blueprints/v1/models.md when used
|
||||
# Update website/docs/customize/blueprints/v1/models.mdx when used
|
||||
SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry"
|
||||
|
||||
|
||||
@@ -154,13 +155,16 @@ class Importer:
|
||||
@staticmethod
|
||||
def from_string(yaml_input: str, context: dict | None = None) -> Importer:
|
||||
"""Parse YAML string and create blueprint importer from it"""
|
||||
import_dict = load(yaml_input, BlueprintLoader)
|
||||
try:
|
||||
import_dict = load(yaml_input, BlueprintLoader)
|
||||
except YAMLError as exc:
|
||||
raise EntryInvalidError(exc) from exc
|
||||
try:
|
||||
_import = from_dict(
|
||||
Blueprint, import_dict, config=Config(cast=[BlueprintEntryDesiredState])
|
||||
)
|
||||
except DaciteError as exc:
|
||||
raise EntryInvalidError from exc
|
||||
raise EntryInvalidError(exc) from exc
|
||||
return Importer(_import, context)
|
||||
|
||||
@property
|
||||
|
||||
@@ -75,19 +75,19 @@ class SchemaBuilder:
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/blueprint_entry"},
|
||||
"items": {"$ref": "#/definitions/blueprint_entry"},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/blueprint_entry"},
|
||||
"items": {"$ref": "#/definitions/blueprint_entry"},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"$defs": {"blueprint_entry": {"oneOf": []}},
|
||||
"definitions": {"blueprint_entry": {"oneOf": []}},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -118,7 +118,7 @@ class SchemaBuilder:
|
||||
}
|
||||
)
|
||||
model_path = f"{model._meta.app_label}.{model._meta.model_name}"
|
||||
self.schema["$defs"]["blueprint_entry"]["oneOf"].append(
|
||||
self.schema["definitions"]["blueprint_entry"]["oneOf"].append(
|
||||
self.template_entry(model_path, model, serializer)
|
||||
)
|
||||
|
||||
@@ -127,11 +127,11 @@ class SchemaBuilder:
|
||||
model_schema = self.to_jsonschema(serializer)
|
||||
model_schema["required"] = []
|
||||
def_name = f"model_{model_path}"
|
||||
def_path = f"#/$defs/{def_name}"
|
||||
self.schema["$defs"][def_name] = model_schema
|
||||
def_path = f"#/definitions/{def_name}"
|
||||
self.schema["definitions"][def_name] = model_schema
|
||||
def_name_perm = f"model_{model_path}_permissions"
|
||||
def_path_perm = f"#/$defs/{def_name_perm}"
|
||||
self.schema["$defs"][def_name_perm] = self.model_permissions(model)
|
||||
def_path_perm = f"#/definitions/{def_name_perm}"
|
||||
self.schema["definitions"][def_name_perm] = self.model_permissions(model)
|
||||
template = {
|
||||
"type": "object",
|
||||
"required": ["model", "identifiers"],
|
||||
|
||||
@@ -126,7 +126,7 @@ def blueprints_find() -> list[BlueprintFile]:
|
||||
for path in root.rglob("**/*.yaml"):
|
||||
rel_path = path.relative_to(root)
|
||||
# Check if any part in the path starts with a dot and assume a hidden file
|
||||
if any(part for part in path.parts if part.startswith(".")):
|
||||
if any(part for part in rel_path.parts if part.startswith(".")):
|
||||
continue
|
||||
with open(path, encoding="utf-8") as blueprint_file:
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Serializer for brands models"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, get_args
|
||||
|
||||
from django.db import models
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||
from drf_spectacular.plumbing import build_basic_type, build_object_type
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
@@ -20,6 +22,7 @@ from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.brands.utils import session_safe_mode
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer, ThemedUrlsSerializer
|
||||
from authentik.rbac.filters import SecretKeyFilter
|
||||
@@ -58,13 +61,16 @@ class BrandSerializer(ModelSerializer):
|
||||
"branding_favicon",
|
||||
"branding_custom_css",
|
||||
"branding_default_flow_background",
|
||||
"branding_map_tiles",
|
||||
"flow_authentication",
|
||||
"flow_user_switch",
|
||||
"flow_invalidation",
|
||||
"flow_recovery",
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"flow_device_code",
|
||||
"flow_lockdown",
|
||||
"flow_request",
|
||||
"default_application",
|
||||
"web_certificate",
|
||||
"client_certificates",
|
||||
@@ -90,6 +96,33 @@ def get_default_ui_footer_links():
|
||||
return get_current_tenant().footer_links
|
||||
|
||||
|
||||
class PublicFlagsField(FlagJSONField):
|
||||
pass
|
||||
|
||||
|
||||
class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
"""Generate API Schema for JSON fields as"""
|
||||
|
||||
target_class = "authentik.brands.api.PublicFlagsField"
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
props = {}
|
||||
# Public flags are always present; authenticated flags are only present for
|
||||
# authenticated requests, so they are exposed in the schema but not required.
|
||||
required = []
|
||||
for visibility in ("public", "authenticated"):
|
||||
for flag in Flag.available(visibility=visibility):
|
||||
_flag = flag()
|
||||
props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0])
|
||||
if _flag.description:
|
||||
props[_flag.key]["description"] = _flag.description
|
||||
if _flag.deprecated:
|
||||
props[_flag.key]["deprecated"] = _flag.deprecated
|
||||
if visibility == "public" and not _flag.deprecated:
|
||||
required.append(_flag.key)
|
||||
return build_object_type(props, required=required)
|
||||
|
||||
|
||||
class CurrentBrandSerializer(PassiveSerializer):
|
||||
"""Partial brand information for styling"""
|
||||
|
||||
@@ -100,6 +133,7 @@ class CurrentBrandSerializer(PassiveSerializer):
|
||||
branding_favicon = CharField(source="branding_favicon_url")
|
||||
branding_favicon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
|
||||
branding_custom_css = CharField()
|
||||
branding_map_tiles = CharField()
|
||||
ui_footer_links = ListField(
|
||||
child=FooterLinkSerializer(),
|
||||
read_only=True,
|
||||
@@ -113,23 +147,39 @@ class CurrentBrandSerializer(PassiveSerializer):
|
||||
)
|
||||
|
||||
flow_authentication = CharField(source="flow_authentication.slug", required=False)
|
||||
flow_user_switch = CharField(source="flow_user_switch.slug", required=False)
|
||||
flow_invalidation = CharField(source="flow_invalidation.slug", required=False)
|
||||
flow_recovery = CharField(source="flow_recovery.slug", required=False)
|
||||
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
|
||||
flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
|
||||
flow_device_code = CharField(source="flow_device_code.slug", required=False)
|
||||
flow_lockdown = CharField(source="flow_lockdown.slug", required=False)
|
||||
flow_request = CharField(source="flow_request.slug", required=False)
|
||||
|
||||
default_locale = CharField(read_only=True)
|
||||
flags = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(field=FlagJSONField)
|
||||
@extend_schema_field(field=PublicFlagsField)
|
||||
def get_flags(self, _):
|
||||
values = {}
|
||||
for flag in Flag.available(visibility="public"):
|
||||
values[flag().key] = flag.get()
|
||||
visibilities = ["public"]
|
||||
request = self.context.get("request")
|
||||
if request and request.user.is_authenticated:
|
||||
visibilities.append("authenticated")
|
||||
for visibility in visibilities:
|
||||
for flag in Flag.available(visibility=visibility):
|
||||
values[flag().key] = flag.get()
|
||||
return values
|
||||
|
||||
def to_representation(self, instance: Brand) -> dict[str, Any]:
|
||||
data = super().to_representation(instance)
|
||||
# Suppress custom CSS for safe-mode sessions (e.g. recovery links) so that
|
||||
# misconfigured branding cannot prevent a user from reaching the UI to fix it.
|
||||
request = self.context.get("request")
|
||||
if request is not None and session_safe_mode(request):
|
||||
data["branding_custom_css"] = ""
|
||||
return data
|
||||
|
||||
|
||||
class BrandViewSet(UsedByMixin, ModelViewSet):
|
||||
"""Brand Viewset"""
|
||||
@@ -151,12 +201,14 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
|
||||
"branding_favicon",
|
||||
"branding_default_flow_background",
|
||||
"flow_authentication",
|
||||
"flow_user_switch",
|
||||
"flow_invalidation",
|
||||
"flow_recovery",
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"flow_device_code",
|
||||
"flow_lockdown",
|
||||
"flow_request",
|
||||
"web_certificate",
|
||||
"client_certificates",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 5.2.15 on 2026-06-23 15:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_brands", "0012_brand_flow_lockdown"),
|
||||
("authentik_crypto", "0006_certificatekeypair_cert_expiry_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="BrandClientCertificate",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"brand",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_brands.brand",
|
||||
),
|
||||
),
|
||||
(
|
||||
"certificate_key_pair",
|
||||
models.ForeignKey(
|
||||
db_column="certificatekeypair_id",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_brands_brand_client_certificates",
|
||||
"unique_together": {("brand", "certificate_key_pair")},
|
||||
"verbose_name": "Brand Client Certificate",
|
||||
"verbose_name_plural": "Brand Client Certificates",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="brand",
|
||||
name="client_certificates",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Certificates used for client authentication.",
|
||||
through="authentik_brands.BrandClientCertificate",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
25
authentik/brands/migrations/0014_brand_flow_request.py
Normal file
25
authentik/brands/migrations/0014_brand_flow_request.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-20 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_brands", "0013_brandclientcertificate_and_more"),
|
||||
("authentik_flows", "0031_alter_flow_layout"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="brand",
|
||||
name="flow_request",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="brand_request",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
]
|
||||
25
authentik/brands/migrations/0015_brand_flow_user_switch.py
Normal file
25
authentik/brands/migrations/0015_brand_flow_user_switch.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-29 14:16
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_brands", "0014_brand_flow_request"),
|
||||
("authentik_flows", "0031_alter_flow_layout"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="brand",
|
||||
name="flow_user_switch",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="brand_user_switch",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
]
|
||||
26
authentik/brands/migrations/0016_brand_branding_map_tiles.py
Normal file
26
authentik/brands/migrations/0016_brand_branding_map_tiles.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_brands", "0015_brand_flow_user_switch"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="brand",
|
||||
name="branding_map_tiles",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text=(
|
||||
"URL template for the vector tile source used by the events map. "
|
||||
"Supports XYZ templates with {z}, {x} and {y} placeholders, or "
|
||||
"pmtiles:// archive URLs. When empty, the frontend uses the "
|
||||
"bundled hexworld basemap. This value is part of the brand "
|
||||
"information served to unauthenticated clients; do not embed API "
|
||||
"keys or other credentials in it."
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -13,10 +13,29 @@ from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.models import SerializerModel, SimpleThroughModel
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
# Session flag marking a "safe mode" session (e.g. one created via a recovery link).
|
||||
SESSION_KEY_BRAND_SAFE_MODE = "authentik/brands/safe_mode"
|
||||
|
||||
|
||||
# Brand FKs read on the request hot path. select_related pulls them into the
|
||||
# same SELECT to avoid N+1 lazy loads; CurrentBrandSerializer alone reads 7.
|
||||
_BRAND_RELATED_FK_FIELDS = (
|
||||
"flow_authentication",
|
||||
"flow_user_switch",
|
||||
"flow_invalidation",
|
||||
"flow_recovery",
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"flow_device_code",
|
||||
"flow_lockdown",
|
||||
"flow_request",
|
||||
"default_application",
|
||||
)
|
||||
|
||||
|
||||
class Brand(SerializerModel):
|
||||
"""Single brand"""
|
||||
@@ -39,10 +58,25 @@ class Brand(SerializerModel):
|
||||
branding_default_flow_background = FileField(
|
||||
default="/static/dist/assets/images/flow_background.jpg",
|
||||
)
|
||||
branding_map_tiles = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"URL template for the vector tile source used by the events map. "
|
||||
"Supports XYZ templates with {z}, {x} and {y} placeholders, or "
|
||||
"pmtiles:// archive URLs. When empty, the frontend uses the "
|
||||
"bundled hexworld basemap. This value is part of the brand "
|
||||
"information served to unauthenticated clients; do not embed API "
|
||||
"keys or other credentials in it."
|
||||
),
|
||||
)
|
||||
|
||||
flow_authentication = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_authentication"
|
||||
)
|
||||
flow_user_switch = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_user_switch"
|
||||
)
|
||||
flow_invalidation = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_invalidation"
|
||||
)
|
||||
@@ -61,6 +95,9 @@ class Brand(SerializerModel):
|
||||
flow_lockdown = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_lockdown"
|
||||
)
|
||||
flow_request = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_request"
|
||||
)
|
||||
|
||||
default_application = models.ForeignKey(
|
||||
"authentik_core.Application",
|
||||
@@ -85,6 +122,7 @@ class Brand(SerializerModel):
|
||||
default=None,
|
||||
blank=True,
|
||||
help_text=_("Certificates used for client authentication."),
|
||||
through="BrandClientCertificate",
|
||||
)
|
||||
attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
@@ -152,6 +190,25 @@ class Brand(SerializerModel):
|
||||
]
|
||||
|
||||
|
||||
class BrandClientCertificate(SimpleThroughModel):
|
||||
brand = models.ForeignKey(Brand, on_delete=models.CASCADE)
|
||||
certificate_key_pair = models.ForeignKey(
|
||||
CertificateKeyPair, on_delete=models.CASCADE, db_column="certificatekeypair_id"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "authentik_brands_brand_client_certificates"
|
||||
unique_together = (("brand", "certificate_key_pair"),)
|
||||
verbose_name = _("Brand Client Certificate")
|
||||
verbose_name_plural = _("Brand Client Certificates")
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"BrandClientCertificate for Brand {self.brand_id} "
|
||||
f"and CertificateKeyPair {self.certificate_key_pair_id}."
|
||||
)
|
||||
|
||||
|
||||
class WebfingerProvider(models.Model):
|
||||
"""Provider which supports webfinger discovery"""
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.brands.models import SESSION_KEY_BRAND_SAFE_MODE, Brand
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
|
||||
from authentik.lib.generators import generate_id
|
||||
@@ -42,6 +42,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": brand.domain,
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -50,6 +51,24 @@ class TestBrands(APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_current_brand_authenticated_flags(self):
|
||||
"""Authenticated-visibility flags are only exposed to authenticated requests"""
|
||||
|
||||
class _AuthedFlag(Flag[bool], key="brands_test_authed_flag"):
|
||||
default = True
|
||||
visibility = "authenticated"
|
||||
|
||||
create_test_brand()
|
||||
|
||||
# Anonymous requests only see public flags
|
||||
anon = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
|
||||
self.assertNotIn("brands_test_authed_flag", anon["flags"])
|
||||
|
||||
# Authenticated requests additionally see authenticated flags
|
||||
self.client.force_login(create_test_admin_user())
|
||||
authed = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
|
||||
self.assertTrue(authed["flags"]["brands_test_authed_flag"])
|
||||
|
||||
def test_brand_subdomain(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
||||
@@ -64,6 +83,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "custom",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -83,6 +103,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": "fallback",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -98,6 +119,7 @@ class TestBrands(APITestCase):
|
||||
response.pop("flow_authentication", None)
|
||||
response.pop("flow_invalidation", None)
|
||||
response.pop("flow_user_settings", None)
|
||||
response.pop("flow_request", None)
|
||||
self.assertEqual(
|
||||
response,
|
||||
{
|
||||
@@ -107,6 +129,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": "authentik-default",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -123,6 +146,7 @@ class TestBrands(APITestCase):
|
||||
response.pop("flow_authentication", None)
|
||||
response.pop("flow_invalidation", None)
|
||||
response.pop("flow_user_settings", None)
|
||||
response.pop("flow_request", None)
|
||||
self.assertEqual(
|
||||
response,
|
||||
{
|
||||
@@ -132,6 +156,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": "authentik-default",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -150,6 +175,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "custom",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -173,6 +199,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "custom-strong",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": "foo.bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -196,6 +223,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "custom-weak",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": "bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -279,6 +307,7 @@ class TestBrands(APITestCase):
|
||||
"branding_favicon_themed_urls": None,
|
||||
"branding_title": "authentik",
|
||||
"branding_custom_css": "",
|
||||
"branding_map_tiles": "",
|
||||
"matched_domain": brand.domain,
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": "automatic",
|
||||
@@ -297,3 +326,33 @@ class TestBrands(APITestCase):
|
||||
res = self.client.get(reverse("authentik_core:if-user"))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn(brand.branding_custom_css, res.content.decode())
|
||||
|
||||
def test_custom_css_safe_mode(self):
|
||||
"""Custom CSS is suppressed and the safe-mode class is set for safe-mode sessions"""
|
||||
brand = create_test_brand()
|
||||
brand.branding_custom_css = """* {
|
||||
font-family: "Foo bar";
|
||||
}"""
|
||||
brand.save()
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_BRAND_SAFE_MODE] = True
|
||||
session.save()
|
||||
res = self.client.get(reverse("authentik_core:if-user"))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = res.content.decode()
|
||||
self.assertNotIn(brand.branding_custom_css, body)
|
||||
self.assertIn("ak-m-safe-mode", body)
|
||||
# A banner is surfaced informing the user that branding is suppressed.
|
||||
self.assertIn("ak-c-safe-mode", body)
|
||||
self.assertIn("Recovery session is active. Custom branding is disabled.", body)
|
||||
|
||||
def test_current_brand_safe_mode(self):
|
||||
"""Current brand API suppresses custom CSS for safe-mode sessions"""
|
||||
brand = create_test_brand()
|
||||
brand.branding_custom_css = "* { color: red; }"
|
||||
brand.save()
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_BRAND_SAFE_MODE] = True
|
||||
session.save()
|
||||
response = loads(self.client.get(reverse("authentik_api:brand-current")).content.decode())
|
||||
self.assertEqual(response["branding_custom_css"], "")
|
||||
80
authentik/brands/tests/test_current_queries.py
Normal file
80
authentik/brands/tests/test_current_queries.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Test brands"""
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.brands.utils import _BRAND_RELATED_FK_FIELDS, get_brand_for_request
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.flows.models import FlowDesignation
|
||||
|
||||
|
||||
class TestGetBrandForRequestSelectRelated(TestCase):
|
||||
"""``get_brand_for_request`` must hydrate the FK fields read on the
|
||||
request hot path so later access doesn't trigger lazy loads."""
|
||||
|
||||
def setUp(self):
|
||||
Brand.objects.all().delete()
|
||||
self.flow_auth = create_test_flow(designation=FlowDesignation.AUTHENTICATION)
|
||||
self.brand = Brand.objects.create(
|
||||
domain="select-related-test.example.com",
|
||||
flow_authentication=self.flow_auth,
|
||||
)
|
||||
|
||||
def _make_request(self, host: str) -> HttpRequest:
|
||||
request = HttpRequest()
|
||||
request.META["HTTP_HOST"] = host
|
||||
return request
|
||||
|
||||
def test_brand_fks_are_loaded_in_single_query(self):
|
||||
"""Brand FK access after ``get_brand_for_request`` must not trigger
|
||||
extra queries."""
|
||||
request = self._make_request("select-related-test.example.com")
|
||||
with self.assertNumQueries(1):
|
||||
brand = get_brand_for_request(request)
|
||||
_ = brand.flow_authentication
|
||||
_ = brand.flow_authentication.slug if brand.flow_authentication else None
|
||||
_ = brand.flow_invalidation
|
||||
_ = brand.flow_recovery
|
||||
_ = brand.flow_unenrollment
|
||||
_ = brand.flow_user_settings
|
||||
_ = brand.flow_device_code
|
||||
_ = brand.flow_lockdown
|
||||
_ = brand.flow_request
|
||||
_ = brand.default_application
|
||||
|
||||
def test_brand_related_fk_list_complete(self):
|
||||
"""``_BRAND_RELATED_FK_FIELDS`` covers every Flow/Application FK on
|
||||
Brand — fails loud when a new FK is added but not registered here."""
|
||||
actual_fks = {
|
||||
f.name
|
||||
for f in Brand._meta.get_fields()
|
||||
if f.many_to_one and f.related_model is not None
|
||||
}
|
||||
relevant_fks = {
|
||||
name for name in actual_fks if name.startswith("flow_") or name == "default_application"
|
||||
}
|
||||
declared = set(_BRAND_RELATED_FK_FIELDS)
|
||||
missing = relevant_fks - declared
|
||||
self.assertFalse(
|
||||
missing,
|
||||
f"Brand FK fields {missing} aren't in _BRAND_RELATED_FK_FIELDS — "
|
||||
"add them or the request hot path will incur extra queries.",
|
||||
)
|
||||
|
||||
def test_brand_related_fks_all_exist_on_model(self):
|
||||
"""Every entry in ``_BRAND_RELATED_FK_FIELDS`` is a real FK on Brand.
|
||||
``select_related`` raises ``FieldError`` at first use if any entry
|
||||
is stale, which would break every request."""
|
||||
actual_fks = {
|
||||
f.name
|
||||
for f in Brand._meta.get_fields()
|
||||
if f.many_to_one and f.related_model is not None
|
||||
}
|
||||
declared = set(_BRAND_RELATED_FK_FIELDS)
|
||||
extraneous = declared - actual_fks
|
||||
self.assertFalse(
|
||||
extraneous,
|
||||
f"_BRAND_RELATED_FK_FIELDS contains {extraneous} which don't "
|
||||
f"exist on Brand (actual FKs: {sorted(actual_fks)}).",
|
||||
)
|
||||
@@ -9,7 +9,7 @@ from django.utils.html import _json_script_escapes
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from authentik import authentik_full_version
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.brands.models import _BRAND_RELATED_FK_FIELDS, SESSION_KEY_BRAND_SAFE_MODE, Brand
|
||||
from authentik.lib.sentry import get_http_meta
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -17,11 +17,21 @@ _q_default = Q(default=True)
|
||||
DEFAULT_BRAND = Brand(domain="fallback")
|
||||
|
||||
|
||||
def session_safe_mode(request: HttpRequest) -> bool:
|
||||
"""Whether the current session is in brand "safe mode" (e.g. created via a recovery
|
||||
link), in which case lock-out-prone customization such as custom CSS are suppressed."""
|
||||
session = getattr(request, "session", None)
|
||||
if session is None:
|
||||
return False
|
||||
return bool(session.get(SESSION_KEY_BRAND_SAFE_MODE))
|
||||
|
||||
|
||||
def get_brand_for_request(request: HttpRequest) -> Brand:
|
||||
"""Get brand object for current request"""
|
||||
|
||||
brand = (
|
||||
Brand.objects.annotate(
|
||||
Brand.objects.select_related(*_BRAND_RELATED_FK_FIELDS)
|
||||
.annotate(
|
||||
host_domain=Value(request.get_host()),
|
||||
domain_length=Length("domain"),
|
||||
match_priority=Case(
|
||||
@@ -56,13 +66,18 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
|
||||
"""Context Processor that injects brand object into every template"""
|
||||
brand = getattr(request, "brand", DEFAULT_BRAND)
|
||||
tenant = getattr(request, "tenant", Tenant())
|
||||
# Suppress custom CSS for safe-mode sessions so misconfigured branding can't lock a
|
||||
# user out of the UI needed to fix it.
|
||||
safe_mode = session_safe_mode(request)
|
||||
custom_css = "" if safe_mode else str(brand.branding_custom_css)
|
||||
# similarly to `json_script` we escape everything HTML-related, however django
|
||||
# only directly exposes this as a function that also wraps it in a <script> tag
|
||||
# which we dont want for CSS
|
||||
brand_css = mark_safe(str(brand.branding_custom_css).translate(_json_script_escapes)) # nosec
|
||||
brand_css = mark_safe(custom_css.translate(_json_script_escapes)) # nosec
|
||||
return {
|
||||
"brand": brand,
|
||||
"brand_css": brand_css,
|
||||
"safe_mode": safe_mode,
|
||||
"footer_links": tenant.footer_links,
|
||||
"html_meta": {**get_http_meta()},
|
||||
"version": authentik_full_version(),
|
||||
|
||||
@@ -10,6 +10,21 @@ GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||
GRANT_TYPE_PASSWORD = "password" # nosec
|
||||
GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange" # nosec
|
||||
|
||||
# Token type identifiers for the token exchange grant
|
||||
# https://datatracker.ietf.org/doc/html/rfc8693#section-3
|
||||
TOKEN_TYPE_URI_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" # nosec
|
||||
TOKEN_TYPE_URI_JWT = "urn:ietf:params:oauth:token-type:jwt" # nosec
|
||||
# authentik's own built-in Token model (e.g. API tokens), not part of RFC 8693 -- only
|
||||
# meaningful as an actor_token_type, never as a subject_token/requested_token_type
|
||||
TOKEN_TYPE_URI_AUTHENTIK_TOKEN = "goauthentik.io/oauth/token-type/authentik_token" # nosec
|
||||
|
||||
# Access tokens are themselves JWTs, so both identifiers denote the same artifact
|
||||
TOKEN_EXCHANGE_TOKEN_TYPES = {TOKEN_TYPE_URI_ACCESS_TOKEN, TOKEN_TYPE_URI_JWT}
|
||||
# Token types accepted for actor_token specifically (RFC 8693 §4.1) -- adds authentik's
|
||||
# built-in Token model on top of the JWT types accepted for subject_token
|
||||
ACTOR_TOKEN_TYPES = TOKEN_EXCHANGE_TOKEN_TYPES | {TOKEN_TYPE_URI_AUTHENTIK_TOKEN}
|
||||
|
||||
QS_LOGIN_HINT = "login_hint"
|
||||
|
||||
@@ -30,6 +45,9 @@ SCOPE_OPENID = "openid"
|
||||
SCOPE_OPENID_PROFILE = "profile"
|
||||
SCOPE_OPENID_EMAIL = "email"
|
||||
SCOPE_OFFLINE_ACCESS = "offline_access"
|
||||
SCOPE_BOUND_KEY = "bound_key"
|
||||
SCOPE_AUTHENTIK_API = "goauthentik.io/api"
|
||||
SCOPE_AUTHENTIK_DCR = "goauthentik.io/oidc/dcr"
|
||||
|
||||
UI_LOCALES = "ui_locales"
|
||||
|
||||
@@ -38,8 +56,7 @@ PKCE_METHOD_PLAIN = "plain"
|
||||
PKCE_METHOD_S256 = "S256"
|
||||
|
||||
TOKEN_TYPE = "Bearer" # nosec
|
||||
|
||||
SCOPE_AUTHENTIK_API = "goauthentik.io/api"
|
||||
JWT_TYPE_DPOP_ID_TOKEN = "dpop+id_token"
|
||||
|
||||
# URI schemes that are forbidden for redirect URIs
|
||||
FORBIDDEN_URI_SCHEMES = {"javascript", "data", "vbscript"}
|
||||
|
||||
19
authentik/common/saml/utils.py
Normal file
19
authentik/common/saml/utils.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Shared SAML XML helpers"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
|
||||
from lxml.etree import _Element
|
||||
|
||||
|
||||
def get_element_text(element: _Element) -> str:
|
||||
"""Return the full text content of an XML element.
|
||||
|
||||
``Element.text`` only returns the text up to the first child node, so a value
|
||||
containing an XML comment (for example ``admin<!--x-->_user``) is silently
|
||||
truncated to ``admin``. Comment-excluding signature canonicalization
|
||||
(``xml-exc-c14n``) strips those comments before the digest is computed, so a
|
||||
signed value can be truncated after signing while keeping the signature valid.
|
||||
Reading the text via ``itertext()`` keeps the value equal to what was signed.
|
||||
"""
|
||||
return "".join(cast(Iterator[str], element.itertext()))
|
||||
@@ -6,15 +6,17 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.object_attributes import AttributesMixinSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import (
|
||||
Application,
|
||||
ApplicationEntitlement,
|
||||
)
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
|
||||
|
||||
class ApplicationEntitlementSerializer(ModelSerializer):
|
||||
class ApplicationEntitlementSerializer(AttributesMixinSerializer, ModelSerializer):
|
||||
"""ApplicationEntitlement Serializer"""
|
||||
|
||||
def validate_app(self, app: Application) -> Application:
|
||||
@@ -39,7 +41,13 @@ class ApplicationEntitlementSerializer(ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
|
||||
class ApplicationEntitlementViewSet(
|
||||
ConditionalInheritance(
|
||||
"authentik.enterprise.requests.api.apps.ApplicationEntitlementsRequestableMixin"
|
||||
),
|
||||
UsedByMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
"""ApplicationEntitlement Viewset"""
|
||||
|
||||
queryset = ApplicationEntitlement.objects.all()
|
||||
|
||||
@@ -28,8 +28,9 @@ from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer
|
||||
from authentik.core.apps import AppAccessWithoutBindings
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
from authentik.policies.api.exec import PolicyTestResultSerializer
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.engine import ListPolicyEngine, PolicyEngine
|
||||
from authentik.policies.types import CACHE_PREFIX, PolicyResult
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
@@ -104,6 +105,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||
model = Application
|
||||
fields = [
|
||||
"pk",
|
||||
"pbm_uuid",
|
||||
"name",
|
||||
"slug",
|
||||
"provider",
|
||||
@@ -123,11 +125,16 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"meta_hide",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"pbm_uuid": {"read_only": True},
|
||||
"backchannel_providers": {"required": False},
|
||||
}
|
||||
|
||||
|
||||
class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
class ApplicationViewSet(
|
||||
ConditionalInheritance("authentik.enterprise.requests.api.apps.ApplicationsRequestableMixin"),
|
||||
UsedByMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
"""Application Viewset"""
|
||||
|
||||
queryset = (
|
||||
@@ -167,18 +174,22 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
def _get_allowed_applications(
|
||||
self, paginated_apps: Iterator[Application], user: User | None = None
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
apps = list(paginated_apps)
|
||||
if not apps:
|
||||
return []
|
||||
request = self.request._request
|
||||
if user:
|
||||
request = copy(request)
|
||||
request.user = user
|
||||
for application in paginated_apps:
|
||||
engine = PolicyEngine(application, request.user, request)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
applications.append(application)
|
||||
return applications
|
||||
engine = ListPolicyEngine(
|
||||
Application.objects.filter(pk__in=[app.pk for app in apps]), request.user, request
|
||||
)
|
||||
engine.empty_result = AppAccessWithoutBindings.get()
|
||||
engine.build()
|
||||
passing_pks = set(engine.result.values_list("pk", flat=True))
|
||||
# Filter (rather than re-fetch from engine.result) to preserve the original
|
||||
# pagination order and the prefetching already applied by get_queryset().
|
||||
return [app for app in apps if app.pk in passing_pks]
|
||||
|
||||
def _expand_applications(self, applications: list[Application]) -> QuerySet[Application]:
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ from drf_spectacular.utils import (
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
)
|
||||
from rest_framework import mixins, serializers
|
||||
from rest_framework import mixins, serializers, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.request import Request
|
||||
@@ -24,8 +24,10 @@ from authentik.api.validation import validate
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
from authentik.core.signals import admin_authenticated_session_deleted
|
||||
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict
|
||||
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict
|
||||
from authentik.lib.utils.db import chunked_queryset
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
|
||||
@@ -152,6 +154,16 @@ class AuthenticatedSessionViewSet(
|
||||
def bulk_delete(self, request: Request, *, query: BulkDeleteSessionSerializer) -> Response:
|
||||
"""Bulk revoke all sessions for multiple users"""
|
||||
user_pks = query.validated_data.get("user_pks", [])
|
||||
deleted_count, _ = AuthenticatedSession.objects.filter(user_id__in=user_pks).delete()
|
||||
count = 0
|
||||
for session in chunked_queryset(AuthenticatedSession.objects.filter(user_id__in=user_pks)):
|
||||
admin_authenticated_session_deleted.send(self, session=session, request=request)
|
||||
session.delete()
|
||||
count += 1
|
||||
|
||||
return Response({"deleted": deleted_count}, status=200)
|
||||
return Response({"deleted": count}, status=200)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
admin_authenticated_session_deleted.send(self, instance=instance, request=request)
|
||||
self.perform_destroy(instance)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -30,6 +30,7 @@ from authentik.api.search.fields import (
|
||||
JSONSearchField,
|
||||
)
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.object_attributes import AttributesMixinSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import Group, User
|
||||
@@ -38,9 +39,19 @@ from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
|
||||
class RawPKList(list):
|
||||
"""List of raw PKs that can be returned without child object serialization."""
|
||||
|
||||
|
||||
class BulkManyRelatedField(ManyRelatedField):
|
||||
"""ManyRelatedField that validates all PKs in a single query instead of one per PK."""
|
||||
|
||||
def get_attribute(self, instance):
|
||||
prefetched_pk_list = getattr(instance, f"_{self.field_name}_pk_list", None)
|
||||
if prefetched_pk_list is not None:
|
||||
return RawPKList(prefetched_pk_list)
|
||||
return super().get_attribute(instance)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, str) or not hasattr(data, "__iter__"):
|
||||
self.fail("not_a_list", input_type=type(data).__name__)
|
||||
@@ -73,6 +84,8 @@ class BulkManyRelatedField(ManyRelatedField):
|
||||
return list(pk_map.keys())
|
||||
|
||||
def to_representation(self, iterable):
|
||||
if isinstance(iterable, RawPKList):
|
||||
return list(iterable)
|
||||
# For non-prefetched querysets, get PKs directly without loading model instances.
|
||||
# When prefetched, _result_cache is a list (possibly empty); when not, it's None.
|
||||
if hasattr(iterable, "values_list") and getattr(iterable, "_result_cache", None) is None:
|
||||
@@ -146,7 +159,7 @@ class RelatedGroupSerializer(ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class GroupSerializer(ModelSerializer):
|
||||
class GroupSerializer(AttributesMixinSerializer, ModelSerializer):
|
||||
"""Group Serializer"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
@@ -377,12 +390,25 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
|
||||
)
|
||||
)
|
||||
# When include_users=false, skip users prefetch entirely.
|
||||
# BulkManyRelatedField.to_representation will use values_list to get PKs
|
||||
# directly without loading User instances into memory.
|
||||
|
||||
return base_qs
|
||||
|
||||
def _attach_user_pk_lists(self, groups: list[Group]) -> None:
|
||||
"""Batch-load user PKs without materializing User objects."""
|
||||
group_pks = {group.pk for group in groups}
|
||||
if not group_pks:
|
||||
return
|
||||
|
||||
users_by_group = {group_pk: [] for group_pk in group_pks}
|
||||
|
||||
through = User.groups.through
|
||||
for group_pk, user_pk in through.objects.filter(group_id__in=group_pks).values_list(
|
||||
"group_id", "user_id"
|
||||
):
|
||||
users_by_group[group_pk].append(user_pk)
|
||||
|
||||
for group in groups:
|
||||
group._users_pk_list = users_by_group[group.pk]
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
@@ -392,7 +418,21 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
if self.serializer_class(context={"request": self.request})._should_include_users:
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
groups = list(page)
|
||||
self._attach_user_pk_lists(groups)
|
||||
serializer = self.get_serializer(groups, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
groups = list(queryset)
|
||||
self._attach_user_pk_lists(groups)
|
||||
serializer = self.get_serializer(groups, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
|
||||
93
authentik/core/api/object_attributes.py
Normal file
93
authentik/core/api/object_attributes.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, SerializerMethodField
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import AttributesMixin, ObjectAttribute
|
||||
from authentik.lib.utils.dict import get_path_from_dict
|
||||
|
||||
|
||||
class AttributesMixinSerializer(ModelSerializer):
|
||||
|
||||
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
model = self.Meta.model
|
||||
attrs = data.get("attributes", {})
|
||||
attributes = ObjectAttribute.objects.filter(
|
||||
object_type=ContentType.objects.get_for_model(model),
|
||||
enabled=True,
|
||||
)
|
||||
for attr in attributes:
|
||||
value = get_path_from_dict(attrs, attr.key)
|
||||
attr.run_validation(value)
|
||||
return data
|
||||
|
||||
|
||||
class ContentTypeSerializer(ModelSerializer):
|
||||
app_label = CharField(read_only=True)
|
||||
model = CharField(read_only=True)
|
||||
verbose_name_plural = SerializerMethodField()
|
||||
fully_qualified_model = SerializerMethodField()
|
||||
|
||||
def get_fully_qualified_model(self, ct: ContentType) -> str:
|
||||
return f"{ct.app_label}.{ct.model}"
|
||||
|
||||
def get_verbose_name_plural(self, ct: ContentType) -> str:
|
||||
return ct.model_class()._meta.verbose_name_plural
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ("id", "app_label", "model", "verbose_name_plural", "fully_qualified_model")
|
||||
|
||||
|
||||
class ObjectAttributeSerializer(ModelSerializer):
|
||||
|
||||
object_type = CharField()
|
||||
object_type_obj = ContentTypeSerializer(read_only=True, source="object_type")
|
||||
|
||||
def validate_object_type(self, fqm: str) -> ContentType:
|
||||
app_label, _, model = fqm.partition(".")
|
||||
ct = ContentType.objects.filter(app_label=app_label, model=model).first()
|
||||
if not ct or not issubclass(ct.model_class(), AttributesMixin):
|
||||
raise ValidationError("Invalid object type")
|
||||
return ct
|
||||
|
||||
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
if attrs.get("is_unique") and attrs.get("is_array"):
|
||||
raise ValidationError(_("Unique cannot be enabled for arrays."))
|
||||
return super().validate(attrs)
|
||||
|
||||
class Meta:
|
||||
model = ObjectAttribute
|
||||
fields = [
|
||||
"pk",
|
||||
"object_type",
|
||||
"object_type_obj",
|
||||
"enabled",
|
||||
"created",
|
||||
"key",
|
||||
"label",
|
||||
"last_updated",
|
||||
"regex",
|
||||
"type",
|
||||
"group",
|
||||
"managed",
|
||||
"is_unique",
|
||||
"is_required",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"last_updated": {"read_only": True},
|
||||
"created": {"read_only": True},
|
||||
"pk": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
class ObjectAttributeViewSet(ModelViewSet):
|
||||
serializer_class = ObjectAttributeSerializer
|
||||
queryset = ObjectAttribute.objects.all()
|
||||
filterset_fields = ["object_type__model", "object_type__app_label", "enabled"]
|
||||
search_fields = ["key", "label", "group", "object_type__model", "object_type__app_label"]
|
||||
ordering = ["key"]
|
||||
@@ -18,13 +18,17 @@ from authentik.core.models import Provider
|
||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Provider Serializer"""
|
||||
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
|
||||
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
|
||||
assigned_application_slug = ReadOnlyField(
|
||||
source="application.slug", allow_null=True, required=False
|
||||
)
|
||||
assigned_application_name = ReadOnlyField(
|
||||
source="application.name", allow_null=True, required=False
|
||||
)
|
||||
assigned_backchannel_application_slug = ReadOnlyField(
|
||||
source="backchannel_application.slug", allow_null=True
|
||||
source="backchannel_application.slug", allow_null=True, required=False
|
||||
)
|
||||
assigned_backchannel_application_name = ReadOnlyField(
|
||||
source="backchannel_application.name", allow_null=True
|
||||
source="backchannel_application.name", allow_null=True, required=False
|
||||
)
|
||||
|
||||
component = SerializerMethodField()
|
||||
@@ -53,7 +57,12 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"verbose_name_plural",
|
||||
"meta_model_name",
|
||||
]
|
||||
extra_kwargs = {
|
||||
# This serializer is only a general read serializer for listing/reading
|
||||
# providers without their specific type,
|
||||
# setting whether authorization_flow/invalidation_flow are required
|
||||
# is up to the child serializer
|
||||
extra_kwargs = {}
|
||||
extra_write_kwargs = {
|
||||
"authorization_flow": {"required": True, "allow_null": False},
|
||||
"invalidation_flow": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ class UserSourceConnectionViewSet(
|
||||
class GroupSourceConnectionSerializer(SourceSerializer):
|
||||
"""Group Source Connection"""
|
||||
|
||||
source_obj = SourceSerializer(read_only=True)
|
||||
source_obj = SourceSerializer(read_only=True, source="source")
|
||||
|
||||
class Meta:
|
||||
model = GroupSourceConnection
|
||||
|
||||
@@ -6,15 +6,16 @@ from typing import Any
|
||||
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.models import AnonymousUser, Permission
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy
|
||||
from django_filters.filters import (
|
||||
BooleanFilter,
|
||||
CharFilter,
|
||||
@@ -65,6 +66,8 @@ from authentik.api.search.fields import (
|
||||
from authentik.api.validation import validate
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core import user_switching
|
||||
from authentik.core.api.object_attributes import AttributesMixinSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import (
|
||||
JSONDictField,
|
||||
@@ -81,13 +84,13 @@ from authentik.core.models import (
|
||||
USER_PATH_SERVICE_ACCOUNT,
|
||||
USERNAME_MAX_LENGTH,
|
||||
Group,
|
||||
Session,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
UserTypes,
|
||||
default_token_duration,
|
||||
)
|
||||
from authentik.core.views.user_switch import start_user_switch_flow
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
@@ -97,6 +100,7 @@ from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.lib.validators import validate_password_hash
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.models import Role, get_permission_choices
|
||||
@@ -107,10 +111,6 @@ from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
INVALID_PASSWORD_HASH_MESSAGE = gettext_lazy(
|
||||
"Invalid password hash format. Must be a valid Django password hash."
|
||||
)
|
||||
|
||||
|
||||
class ParamUserSerializer(PassiveSerializer):
|
||||
"""Partial serializer for query parameters to select a user"""
|
||||
@@ -134,7 +134,7 @@ class PartialGroupSerializer(ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class UserSerializer(ModelSerializer):
|
||||
class UserSerializer(AttributesMixinSerializer, ModelSerializer):
|
||||
"""User Serializer"""
|
||||
|
||||
is_superuser = SerializerMethodField()
|
||||
@@ -212,7 +212,6 @@ class UserSerializer(ModelSerializer):
|
||||
password = validated_data.pop("password", None)
|
||||
password_hash = validated_data.pop("password_hash", None)
|
||||
permissions = validated_data.pop("permissions", [])
|
||||
self._validate_password_inputs(password, password_hash)
|
||||
|
||||
instance: User = super().create(validated_data)
|
||||
if is_blueprint:
|
||||
@@ -232,7 +231,6 @@ class UserSerializer(ModelSerializer):
|
||||
password = validated_data.pop("password", None)
|
||||
password_hash = validated_data.pop("password_hash", None)
|
||||
permissions = validated_data.pop("permissions", [])
|
||||
self._validate_password_inputs(password, password_hash)
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
if is_blueprint:
|
||||
@@ -245,18 +243,6 @@ class UserSerializer(ModelSerializer):
|
||||
self._ensure_password_not_empty(instance)
|
||||
return instance
|
||||
|
||||
def _validate_password_inputs(self, password: str | None, password_hash: str | None):
|
||||
"""Validate mutually-exclusive password inputs before any model mutation."""
|
||||
if password is not None and password_hash is not None:
|
||||
raise ValidationError(_("Cannot set both password and password_hash. Use only one."))
|
||||
if password_hash is None:
|
||||
return
|
||||
try:
|
||||
User.validate_password_hash(password_hash)
|
||||
except ValueError as exc:
|
||||
LOGGER.warning("Failed to identify password hash format", exc_info=exc)
|
||||
raise ValidationError(INVALID_PASSWORD_HASH_MESSAGE) from exc
|
||||
|
||||
def _set_password(self, instance: User, password: str | None, password_hash: str | None = None):
|
||||
"""Set password from plain text or hash."""
|
||||
if password_hash is not None:
|
||||
@@ -287,14 +273,19 @@ class UserSerializer(ModelSerializer):
|
||||
|
||||
def validate_type(self, user_type: str) -> str:
|
||||
"""Validate user type, internal_service_account is an internal value"""
|
||||
if (
|
||||
self.instance
|
||||
and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT
|
||||
and user_type != UserTypes.INTERNAL_SERVICE_ACCOUNT.value
|
||||
):
|
||||
raise ValidationError(_("Can't change internal service account to other user type."))
|
||||
if not self.instance and user_type == UserTypes.INTERNAL_SERVICE_ACCOUNT.value:
|
||||
raise ValidationError(_("Setting a user to internal service account is not allowed."))
|
||||
if not self.instance and user_type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
|
||||
raise ValidationError(_("Can't create internal service accounts"))
|
||||
if self.instance:
|
||||
if (
|
||||
self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT
|
||||
and user_type != UserTypes.INTERNAL_SERVICE_ACCOUNT.value
|
||||
) or (
|
||||
self.instance.type != UserTypes.INTERNAL_SERVICE_ACCOUNT
|
||||
and user_type == UserTypes.INTERNAL_SERVICE_ACCOUNT.value
|
||||
):
|
||||
raise ValidationError(
|
||||
_("Can't change internal service account to other user type.")
|
||||
)
|
||||
return user_type
|
||||
|
||||
def validate_groups(self, groups: list) -> list:
|
||||
@@ -328,6 +319,12 @@ class UserSerializer(ModelSerializer):
|
||||
return roles
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
if (
|
||||
SERIALIZER_CONTEXT_BLUEPRINT in self.context
|
||||
and attrs.get("password") is not None
|
||||
and attrs.get("password_hash") is not None
|
||||
):
|
||||
raise ValidationError(_("Cannot set both password and password_hash. Use only one."))
|
||||
if self.instance and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
|
||||
raise ValidationError(_("Can't modify internal service account users"))
|
||||
return super().validate(attrs)
|
||||
@@ -367,6 +364,7 @@ class UserSelfSerializer(ModelSerializer):
|
||||
"""User Serializer for information a user can retrieve about themselves"""
|
||||
|
||||
is_superuser = BooleanField(read_only=True)
|
||||
is_current = SerializerMethodField()
|
||||
avatar = SerializerMethodField()
|
||||
groups = SerializerMethodField()
|
||||
roles = SerializerMethodField()
|
||||
@@ -378,6 +376,10 @@ class UserSelfSerializer(ModelSerializer):
|
||||
"""User's avatar, either a http/https URL or a data URI"""
|
||||
return get_avatar(user, self.context.get("request"))
|
||||
|
||||
def get_is_current(self, _: User) -> bool:
|
||||
"""Return whether this user owns the current browser session."""
|
||||
return self.context.get("is_current", False)
|
||||
|
||||
@extend_schema_field(
|
||||
ListSerializer(
|
||||
child=inline_serializer(
|
||||
@@ -436,6 +438,7 @@ class UserSelfSerializer(ModelSerializer):
|
||||
"name",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
"is_current",
|
||||
"groups",
|
||||
"roles",
|
||||
"email",
|
||||
@@ -451,6 +454,31 @@ class UserSelfSerializer(ModelSerializer):
|
||||
}
|
||||
|
||||
|
||||
class UserSwitchAction(models.TextChoices):
|
||||
"""Actions supported by the user switch endpoint."""
|
||||
|
||||
ADD = "add"
|
||||
SWITCH = "switch"
|
||||
|
||||
|
||||
class UserSwitchSerializer(PassiveSerializer):
|
||||
"""Request to add or switch users in the current browser."""
|
||||
|
||||
action = ChoiceField(choices=UserSwitchAction.choices, default=UserSwitchAction.SWITCH)
|
||||
user_pk = IntegerField(required=False)
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
if attrs["action"] == UserSwitchAction.SWITCH and "user_pk" not in attrs:
|
||||
raise ValidationError({"user_pk": _("This field is required.")})
|
||||
return attrs
|
||||
|
||||
|
||||
class UserSwitchResponseSerializer(PassiveSerializer):
|
||||
"""Redirect returned after planning a user switch."""
|
||||
|
||||
redirect = CharField(read_only=True)
|
||||
|
||||
|
||||
class SessionUserSerializer(PassiveSerializer):
|
||||
"""Response for the /user/me endpoint, returns the currently active user (as `user` property)
|
||||
and, if this user is being impersonated, the original user in the `original` property.
|
||||
@@ -458,6 +486,7 @@ class SessionUserSerializer(PassiveSerializer):
|
||||
|
||||
user = UserSelfSerializer()
|
||||
original = UserSelfSerializer(required=False)
|
||||
users = UserSelfSerializer(many=True)
|
||||
|
||||
|
||||
class UserPasswordSetSerializer(PassiveSerializer):
|
||||
@@ -469,7 +498,7 @@ class UserPasswordSetSerializer(PassiveSerializer):
|
||||
class UserPasswordHashSetSerializer(PassiveSerializer):
|
||||
"""Payload to set a users' password hash directly"""
|
||||
|
||||
password = CharField(required=True)
|
||||
password = CharField(required=True, validators=[validate_password_hash])
|
||||
|
||||
|
||||
class UserServiceAccountSerializer(PassiveSerializer):
|
||||
@@ -526,7 +555,7 @@ class UsersFilter(FilterSet):
|
||||
uuid = UUIDFilter(field_name="uuid")
|
||||
|
||||
path = CharFilter(field_name="path")
|
||||
path_startswith = CharFilter(field_name="path", lookup_expr="startswith")
|
||||
path_startswith = CharFilter(field_name="path", method="filter_path_startswith")
|
||||
|
||||
type = MultipleChoiceFilter(choices=UserTypes.choices, field_name="type")
|
||||
|
||||
@@ -555,6 +584,15 @@ class UsersFilter(FilterSet):
|
||||
return queryset.filter(groups__is_superuser=True).distinct()
|
||||
return queryset.exclude(groups__is_superuser=True).distinct()
|
||||
|
||||
def filter_path_startswith(self, queryset, name, value):
|
||||
"""Filter users by the given path and any of its sub-paths. A plain `startswith`
|
||||
lookup would also match sibling paths sharing the same prefix, so that `foo/bar`
|
||||
would incorrectly match users in `foo/bar2`."""
|
||||
value = value.rstrip("/")
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(Q(path=value) | Q(path__startswith=f"{value}/"))
|
||||
|
||||
def filter_attributes(self, queryset, name, value):
|
||||
"""Filter attributes by query args"""
|
||||
try:
|
||||
@@ -661,6 +699,31 @@ class UserViewSet(
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[OpenApiParameter("next", str, required=False)],
|
||||
request=UserSwitchSerializer,
|
||||
responses={200: UserSwitchResponseSerializer},
|
||||
)
|
||||
@action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
permission_classes=[IsAuthenticated],
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
)
|
||||
@validate(UserSwitchSerializer)
|
||||
def switch(self, request: Request, body: UserSwitchSerializer) -> HttpResponse | Response:
|
||||
"""Start browser user switching."""
|
||||
user_pk = (
|
||||
None
|
||||
if body.validated_data["action"] == UserSwitchAction.ADD
|
||||
else body.validated_data["user_pk"]
|
||||
)
|
||||
response = start_user_switch_flow(request._request, user_pk)
|
||||
if isinstance(response, HttpResponseRedirect):
|
||||
return Response({"redirect": response.url})
|
||||
return response
|
||||
|
||||
def _create_recovery_link(
|
||||
self, token_duration: str | None, for_email=False
|
||||
) -> tuple[str, Token]:
|
||||
@@ -806,14 +869,34 @@ class UserViewSet(
|
||||
)
|
||||
def user_me(self, request: Request) -> Response:
|
||||
"""Get information about current user"""
|
||||
context = {"request": request}
|
||||
context = {"request": request, "is_current": True}
|
||||
users = []
|
||||
user_switching_token = getattr(request._request, "user_switching_token", None)
|
||||
if request.user.is_authenticated and user_switching_token:
|
||||
sessions = (
|
||||
user_switching.live_sessions(user_switching_token)
|
||||
.exclude(user_id=request.user.pk)
|
||||
.select_related("session", "user")
|
||||
.order_by("user_id", "-session__last_used")
|
||||
.distinct("user_id")
|
||||
)
|
||||
users = [
|
||||
UserSelfSerializer(
|
||||
instance=authenticated_session.user,
|
||||
context={"request": request},
|
||||
).data
|
||||
for authenticated_session in sessions
|
||||
]
|
||||
serializer = SessionUserSerializer(
|
||||
data={"user": UserSelfSerializer(instance=request.user, context=context).data}
|
||||
data={
|
||||
"user": UserSelfSerializer(instance=request.user, context=context).data,
|
||||
"users": users,
|
||||
}
|
||||
)
|
||||
if SESSION_KEY_IMPERSONATE_USER in request._request.session:
|
||||
serializer.initial_data["original"] = UserSelfSerializer(
|
||||
instance=request._request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER],
|
||||
context=context,
|
||||
context={"request": request},
|
||||
).data
|
||||
self.request.session.modified = True
|
||||
return Response(serializer.initial_data)
|
||||
@@ -878,9 +961,6 @@ class UserViewSet(
|
||||
try:
|
||||
user.set_password_from_hash(body.validated_data["password"], request=request)
|
||||
user.save()
|
||||
except ValueError as exc:
|
||||
LOGGER.debug("Failed to set password hash", exc=exc)
|
||||
return Response(data={"password": [INVALID_PASSWORD_HASH_MESSAGE]}, status=400)
|
||||
except (ValidationError, IntegrityError) as exc:
|
||||
LOGGER.debug("Failed to set password hash", exc=exc)
|
||||
return Response(status=400)
|
||||
@@ -1045,11 +1125,3 @@ class UserViewSet(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def partial_update(self, request: Request, *args, **kwargs) -> Response:
|
||||
response = super().partial_update(request, *args, **kwargs)
|
||||
instance: User = self.get_object()
|
||||
if not instance.is_active:
|
||||
Session.objects.filter(authenticatedsession__user=instance).delete()
|
||||
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
||||
return response
|
||||
|
||||
@@ -21,6 +21,8 @@ from rest_framework.serializers import (
|
||||
raise_errors_on_nested_writes,
|
||||
)
|
||||
|
||||
from authentik.lib.models import SimpleThroughModel
|
||||
|
||||
|
||||
def is_dict(value: Any):
|
||||
"""Ensure a value is a dictionary, useful for JSONFields"""
|
||||
@@ -80,6 +82,19 @@ class ModelSerializer(BaseModelSerializer):
|
||||
|
||||
return instance
|
||||
|
||||
# To be safe, DRF handles explicit through models differently than implicit ones, for example,
|
||||
# it marks them as `read_only`. However, for "simple" through models, consisting of only the ids
|
||||
# of the related objects, we'd like DRF to handle them as if they were automatically created.
|
||||
def build_relational_field(self, field_name, relation_info):
|
||||
if (
|
||||
relation_info.model_field is not None
|
||||
and relation_info.model_field.many_to_many
|
||||
and issubclass(relation_info.model_field.remote_field.through, SimpleThroughModel)
|
||||
):
|
||||
relation_info = relation_info._replace(has_through_model=False)
|
||||
|
||||
return super().build_relational_field(field_name, relation_info)
|
||||
|
||||
|
||||
class PassiveSerializer(Serializer):
|
||||
"""Base serializer class which doesn't implement create/update methods"""
|
||||
|
||||
@@ -17,10 +17,7 @@ class AppAccessWithoutBindings(Flag[bool], key="core_default_app_access"):
|
||||
|
||||
default = True
|
||||
visibility = "none"
|
||||
description = _(
|
||||
"Configure if applications without any policy/group/user bindings "
|
||||
"should be accessible to any user."
|
||||
)
|
||||
description = _("Applications with no policies bound can be accessed by any user.")
|
||||
|
||||
|
||||
class AuthentikCoreConfig(ManagedAppConfig):
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Hash password using Django's password hashers"""
|
||||
|
||||
import sys
|
||||
from getpass import getpass
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
@@ -7,22 +10,21 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
class Command(BaseCommand):
|
||||
"""Hash a password using Django's password hashers"""
|
||||
|
||||
help = "Hash a password for use with AUTHENTIK_BOOTSTRAP_PASSWORD_HASH"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"password",
|
||||
type=str,
|
||||
help="Password to hash",
|
||||
)
|
||||
help = (
|
||||
"Hash a password for use with AUTHENTIK_BOOTSTRAP_PASSWORD_HASH. Prompt when "
|
||||
"interactive, or read the password from standard input."
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
password = options["password"]
|
||||
if sys.stdin.isatty():
|
||||
password = getpass("Password: ")
|
||||
password_again = getpass("Password (again): ")
|
||||
if password != password_again:
|
||||
raise CommandError("Passwords do not match")
|
||||
else:
|
||||
password = input()
|
||||
|
||||
if not password:
|
||||
raise CommandError("Password cannot be empty")
|
||||
try:
|
||||
hashed = make_password(password)
|
||||
self.stdout.write(hashed)
|
||||
except ValueError as exc:
|
||||
raise CommandError(f"Error hashing password: {exc}") from exc
|
||||
hashed = make_password(password)
|
||||
self.stdout.write(hashed)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-21 18:49
|
||||
from django.apps.registry import Apps
|
||||
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def check_is_already_setup(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from django.conf import settings
|
||||
from authentik.flows.models import FlowAuthenticationRequirement
|
||||
from django.conf import settings
|
||||
|
||||
VersionHistory = apps.get_model("authentik_admin", "VersionHistory")
|
||||
Flow = apps.get_model("authentik_flows", "Flow")
|
||||
@@ -41,13 +39,20 @@ def check_is_already_setup(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
|
||||
def update_setup_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
is_already_setup = check_is_already_setup(apps, schema_editor)
|
||||
if is_already_setup:
|
||||
tenant = get_current_tenant()
|
||||
Tenant = apps.get_model("authentik_tenants", "Tenant")
|
||||
db_alias = schema_editor.connection.alias
|
||||
tenant = (
|
||||
Tenant.objects.using(db_alias)
|
||||
.filter(schema_name=schema_editor.connection.schema_name)
|
||||
.first()
|
||||
)
|
||||
if tenant is None:
|
||||
return
|
||||
tenant.flags[Setup().key] = True
|
||||
tenant.save()
|
||||
tenant.save(update_fields=["flags"], using=db_alias)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -56,6 +61,8 @@ class Migration(migrations.Migration):
|
||||
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
|
||||
# 0024_flow_authentication adds the `authentication` field.
|
||||
("authentik_flows", "0024_flow_authentication"),
|
||||
# 0006_tenant_flags adds the `flags` field this migration updates.
|
||||
("authentik_tenants", "0006_tenant_flags"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(update_setup_flag, migrations.RunPython.noop)]
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# Generated by Django 5.2.15 on 2026-06-23 15:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0059_add_application_meta_hide"),
|
||||
("authentik_rbac", "0010_remove_role_group_alter_role_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="SourceUserPropertyMapping",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"property_mapping",
|
||||
models.ForeignKey(
|
||||
db_column="propertymapping_id",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.source",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_core_source_user_property_mappings",
|
||||
"unique_together": {("property_mapping", "source")},
|
||||
"verbose_name": "Source User Property Mapping",
|
||||
"verbose_name_plural": "Source User Property Mappings",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="source",
|
||||
name="user_property_mappings",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="source_userpropertymappings_set",
|
||||
through="authentik_core.SourceUserPropertyMapping",
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="SourceGroupPropertyMapping",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"property_mapping",
|
||||
models.ForeignKey(
|
||||
db_column="propertymapping_id",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.source",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_core_source_group_property_mappings",
|
||||
"unique_together": {("property_mapping", "source")},
|
||||
"verbose_name": "Source Group Property Mapping",
|
||||
"verbose_name_plural": "Source Group Property Mappings",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="source",
|
||||
name="group_property_mappings",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="source_grouppropertymappings_set",
|
||||
through="authentik_core.SourceGroupPropertyMapping",
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="ProviderPropertyMapping",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"property_mapping",
|
||||
models.ForeignKey(
|
||||
db_column="propertymapping_id",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.provider",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_core_provider_property_mappings",
|
||||
"unique_together": {("property_mapping", "provider")},
|
||||
"verbose_name": "Provider Property Mapping",
|
||||
"verbose_name_plural": "Provider Property Mappings",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="provider",
|
||||
name="property_mappings",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
through="authentik_core.ProviderPropertyMapping",
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="UserRole",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_rbac.role",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_core_user_roles",
|
||||
"unique_together": {("user", "role")},
|
||||
"verbose_name": "User Role",
|
||||
"verbose_name_plural": "User Roles",
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="GroupRole",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_rbac.role",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_core_group_roles",
|
||||
"unique_together": {("group", "role")},
|
||||
"verbose_name": "Group Role",
|
||||
"verbose_name_plural": "Group Roles",
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.AlterField(
|
||||
model_name="group",
|
||||
name="roles",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="groups",
|
||||
through="authentik_core.GroupRole",
|
||||
to="authentik_rbac.role",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="roles",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="users",
|
||||
through="authentik_core.UserRole",
|
||||
to="authentik_rbac.role",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserGroup",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "authentik_core_user_groups",
|
||||
"unique_together": {("user", "group")},
|
||||
"verbose_name": "User Group",
|
||||
"verbose_name_plural": "User Groups",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="groups",
|
||||
field=models.ManyToManyField(
|
||||
related_name="users",
|
||||
through="authentik_core.UserGroup",
|
||||
to="authentik_core.group",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user