Skip to content

ci: run publish build under Node's permission model (experiment)#22465

Open
GirlBossRush wants to merge 1 commit into
mainfrom
ci/node-permission-publish-build
Open

ci: run publish build under Node's permission model (experiment)#22465
GirlBossRush wants to merge 1 commit into
mainfrom
ci/node-permission-publish-build

Conversation

@GirlBossRush
Copy link
Copy Markdown
Contributor

Summary

Wraps the build phase of .github/workflows/packages-npm-publish.yml (tsc -p ., plus typedoc for packages/esbuild-plugin-live-reload) in Node 24's permission model.

node --permission \
     --allow-fs-read="$PWD" \
     --allow-fs-write="$PWD" \
     ./node_modules/typescript/bin/tsc -p .

--permission with no other --allow flags denies by default:

  • network (--allow-net unset)
  • child_process (--allow-child-process unset)
  • worker_threads (--allow-worker unset)
  • native addons
  • WASI

fs reads and writes are explicitly scoped to the package working tree.

Threat model

A malicious devDep (direct or transitive) of one of the six publishable packages that wakes up during tsc and tries to do anything beyond compile TypeScript. With this in place it can still read process.env (the permission model doesn't gate env vars), but it has no channel to send anything anywhere:

  • no network → can't fetch an exfil endpoint
  • no child_process → can't shell out to curl/nc/ssh
  • no fs write outside $PWD → can't drop a payload into ~/.ssh, /var/tmp, etc.
  • no addons → can't load a native exfil helper

npm ci and npm publish keep their full capability set. Both legitimately need network and subprocess access, and the runner-level egress defense for npm publish lives in #22463 (step-security/harden-runner).

Why this is the right surface

All six publishable packages have build scripts that are either tsc -p . or tsc -p . && typedoc — pure JS tools with no native addons and no in-build subprocess spawning. The permission allow-list is uniform and the failure mode (build fails noisily) is much preferable to a quiet exfil.

Caveats

  • Marked "experiment" in the commit because Node's permission model is stability 1.1 in v24. The flag surface (--permission, --allow-fs-read, --allow-fs-write, --allow-net) is settled, but minor semantics can shift across Node minors.
  • If a future package wants broader behavior (worker_threads, e.g.), the allow-list widens per-step; the wrapper does not need to be discarded.
  • If tsc or typedoc upstream starts touching paths outside $PWD, the build will fail with a permission error; revert is a one-line removal.

Test plan

  • Trigger the workflow via workflow_dispatch against this branch — all six matrix entries' build phase completes under --permission.
  • npm publish still succeeds end-to-end (capability boundary preserved between phases).
  • If anything fails: error is ERR_ACCESS_DENIED with the path that wasn't allowed; we widen the list precisely.

@GirlBossRush GirlBossRush requested a review from a team as a code owner May 19, 2026 11:44
@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

❌ 7 Tests Failed:

Tests completed Failed Passed Skipped
3571 7 3564 1
View the top 3 failed test(s) by shortest run time
authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests::test_parse_nginx
Stack Traces | 0.709s run time
self = <unittest.case._Outcome object at 0x7f1bad38bd20>
test_case = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>
result = <TestCaseFunction test_parse_nginx>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
>                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:669: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>
method = <bound method MTLSStageTests.test_parse_nginx of <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>>

    def _callTestMethod(self, method):
>       result = method()
                 ^^^^^^^^

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:615: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>

    def test_parse_nginx(self):
        """Test nginx's format"""
        with self.assertFlowFinishes() as plan:
            res = self.client.get(
                reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
                headers={"SSL-Client-Cert": quote_plus(self.client_cert)},
            )
            self.assertEqual(res.status_code, 200)
>           self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))

.../mtls/tests/test_stage.py:79: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>
response = <HttpChallengeResponse status_code=200, "application/json">, to = '/'

    def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
        """Wrapper around assertStageResponse that checks for a redirect"""
>       return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.../flows/tests/__init__.py:61: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>
response = <HttpChallengeResponse status_code=200, "application/json">
flow = None, user = None, kwargs = {'component': 'xak-flow-redirect', 'to': '/'}
raw_response = {'component': 'ak-stage-access-denied', 'error_message': 'Certificate required but no certificate was given.', 'flow_i..., 'background_themed_urls': None, 'cancel_url': '.../flows/-/cancel/', 'layout': 'stacked', ...}, 'pending_user': '', ...}
key = 'component', expected = 'xak-flow-redirect'

    def assertStageResponse(
        self,
        response: HttpResponse,
        flow: Flow | None = None,
        user: User | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """Assert various attributes of a stage response"""
        self.assertEqual(response.status_code, 200)
        raw_response = loads(response.content.decode())
        self.assertIsNotNone(raw_response["component"])
        if flow:
            self.assertIn("flow_info", raw_response)
            self.assertTrue(
                raw_response["flow_info"]["cancel_url"].startswith(
                    reverse("authentik_flows:cancel")
                )
            )
            # We don't check the flow title since it will most likely go
            # through ChallengeStageView.format_title() so might not match 1:1
            # self.assertEqual(raw_response["flow_info"]["title"], flow.title)
            self.assertIsNotNone(raw_response["flow_info"]["title"])
        if user:
            self.assertEqual(raw_response["pending_user"], user.username)
            self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
        for key, expected in kwargs.items():
>           self.assertEqual(raw_response[key], expected)

.../flows/tests/__init__.py:48: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertEqual(self, first, second, msg=None):
        """Fail if the two objects are unequal as determined by the '=='
           operator.
        """
        assertion_func = self._getAssertEqualityFunc(first, second)
>       assertion_func(first, second, msg=msg)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:925: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertMultiLineEqual(self, first, second, msg=None):
        """Assert that two multi-line strings are equal."""
        self.assertIsInstance(first, str, "First argument is not a string")
        self.assertIsInstance(second, str, "Second argument is not a string")
    
        if first != second:
            # Don't use difflib if the strings are too long
            if (len(first) > self._diffThreshold or
                len(second) > self._diffThreshold):
                self._baseAssertEqual(first, second, msg)
    
            # Append \n to both strings if either is missing the \n.
            # This allows the final ndiff to show the \n difference. The
            # exception here is if the string is empty, in which case no
            # \n should be added
            first_presplit = first
            second_presplit = second
            if first and second:
                if first[-1] != '\n' or second[-1] != '\n':
                    first_presplit += '\n'
                    second_presplit += '\n'
            elif second and second[-1] != '\n':
                second_presplit += '\n'
            elif first and first[-1] != '\n':
                first_presplit += '\n'
    
            firstlines = first_presplit.splitlines(keepends=True)
            secondlines = second_presplit.splitlines(keepends=True)
    
            # Generate the message and diff, then raise the exception
            standardMsg = '%s != %s' % _common_shorten_repr(first, second)
            diff = '\n' + ''.join(difflib.ndiff(firstlines, secondlines))
            standardMsg = self._truncateMessage(standardMsg, diff)
>           self.fail(self._formatMessage(msg, standardMsg))

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:1291: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_nginx>
msg = "'ak-stage-access-denied' != 'xak-flow-redirect'\n- ak-stage-access-denied\n+ xak-flow-redirect\n"

    def fail(self, msg=None):
        """Fail immediately, with the given message."""
>       raise self.failureException(msg)
E       AssertionError: 'ak-stage-access-denied' != 'xak-flow-redirect'
E       - ak-stage-access-denied
E       + xak-flow-redirect

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:750: AssertionError
authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests::test_enroll
Stack Traces | 0.721s run time
self = <unittest.case._Outcome object at 0x7f1bb04806e0>
test_case = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>
result = <TestCaseFunction test_enroll>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
>                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:669: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>
method = <bound method MTLSStageTests.test_enroll of <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>>

    def _callTestMethod(self, method):
>       result = method()
                 ^^^^^^^^

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:615: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>

    def test_enroll(self):
        """Test Enrollment flow"""
        self.flow.designation = FlowDesignation.ENROLLMENT
        self.flow.save()
        with self.assertFlowFinishes() as plan:
            res = self.client.get(
                reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
                headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
            )
            self.assertEqual(res.status_code, 200)
>           self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))

.../mtls/tests/test_stage.py:225: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>
response = <HttpChallengeResponse status_code=200, "application/json">, to = '/'

    def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
        """Wrapper around assertStageResponse that checks for a redirect"""
>       return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.../flows/tests/__init__.py:61: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>
response = <HttpChallengeResponse status_code=200, "application/json">
flow = None, user = None, kwargs = {'component': 'xak-flow-redirect', 'to': '/'}
raw_response = {'component': 'ak-stage-access-denied', 'error_message': 'Certificate required but no certificate was given.', 'flow_i..., 'background_themed_urls': None, 'cancel_url': '.../flows/-/cancel/', 'layout': 'stacked', ...}, 'pending_user': '', ...}
key = 'component', expected = 'xak-flow-redirect'

    def assertStageResponse(
        self,
        response: HttpResponse,
        flow: Flow | None = None,
        user: User | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """Assert various attributes of a stage response"""
        self.assertEqual(response.status_code, 200)
        raw_response = loads(response.content.decode())
        self.assertIsNotNone(raw_response["component"])
        if flow:
            self.assertIn("flow_info", raw_response)
            self.assertTrue(
                raw_response["flow_info"]["cancel_url"].startswith(
                    reverse("authentik_flows:cancel")
                )
            )
            # We don't check the flow title since it will most likely go
            # through ChallengeStageView.format_title() so might not match 1:1
            # self.assertEqual(raw_response["flow_info"]["title"], flow.title)
            self.assertIsNotNone(raw_response["flow_info"]["title"])
        if user:
            self.assertEqual(raw_response["pending_user"], user.username)
            self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
        for key, expected in kwargs.items():
>           self.assertEqual(raw_response[key], expected)

.../flows/tests/__init__.py:48: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertEqual(self, first, second, msg=None):
        """Fail if the two objects are unequal as determined by the '=='
           operator.
        """
        assertion_func = self._getAssertEqualityFunc(first, second)
>       assertion_func(first, second, msg=msg)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:925: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertMultiLineEqual(self, first, second, msg=None):
        """Assert that two multi-line strings are equal."""
        self.assertIsInstance(first, str, "First argument is not a string")
        self.assertIsInstance(second, str, "Second argument is not a string")
    
        if first != second:
            # Don't use difflib if the strings are too long
            if (len(first) > self._diffThreshold or
                len(second) > self._diffThreshold):
                self._baseAssertEqual(first, second, msg)
    
            # Append \n to both strings if either is missing the \n.
            # This allows the final ndiff to show the \n difference. The
            # exception here is if the string is empty, in which case no
            # \n should be added
            first_presplit = first
            second_presplit = second
            if first and second:
                if first[-1] != '\n' or second[-1] != '\n':
                    first_presplit += '\n'
                    second_presplit += '\n'
            elif second and second[-1] != '\n':
                second_presplit += '\n'
            elif first and first[-1] != '\n':
                first_presplit += '\n'
    
            firstlines = first_presplit.splitlines(keepends=True)
            secondlines = second_presplit.splitlines(keepends=True)
    
            # Generate the message and diff, then raise the exception
            standardMsg = '%s != %s' % _common_shorten_repr(first, second)
            diff = '\n' + ''.join(difflib.ndiff(firstlines, secondlines))
            standardMsg = self._truncateMessage(standardMsg, diff)
>           self.fail(self._formatMessage(msg, standardMsg))

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:1291: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_enroll>
msg = "'ak-stage-access-denied' != 'xak-flow-redirect'\n- ak-stage-access-denied\n+ xak-flow-redirect\n"

    def fail(self, msg=None):
        """Fail immediately, with the given message."""
>       raise self.failureException(msg)
E       AssertionError: 'ak-stage-access-denied' != 'xak-flow-redirect'
E       - ak-stage-access-denied
E       + xak-flow-redirect

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:750: AssertionError
authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests::test_brand_ca
Stack Traces | 0.722s run time
self = <unittest.case._Outcome object at 0x7f1bb0481240>
test_case = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>
result = <TestCaseFunction test_brand_ca>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
>                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:669: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>
method = <bound method MTLSStageTests.test_brand_ca of <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>>

    def _callTestMethod(self, method):
>       result = method()
                 ^^^^^^^^

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:615: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>

    def test_brand_ca(self):
        """Test using a CA from the brand"""
        self.stage.certificate_authorities.clear()
    
        brand = create_test_brand()
        brand.client_certificates.add(self.ca)
        with self.assertFlowFinishes() as plan:
            res = self.client.get(
                reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
                headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
            )
            self.assertEqual(res.status_code, 200)
>           self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))

.../mtls/tests/test_stage.py:179: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>
response = <HttpChallengeResponse status_code=200, "application/json">, to = '/'

    def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
        """Wrapper around assertStageResponse that checks for a redirect"""
>       return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.../flows/tests/__init__.py:61: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>
response = <HttpChallengeResponse status_code=200, "application/json">
flow = None, user = None, kwargs = {'component': 'xak-flow-redirect', 'to': '/'}
raw_response = {'component': 'ak-stage-access-denied', 'error_message': 'Certificate required but no certificate was given.', 'flow_i..., 'background_themed_urls': None, 'cancel_url': '.../flows/-/cancel/', 'layout': 'stacked', ...}, 'pending_user': '', ...}
key = 'component', expected = 'xak-flow-redirect'

    def assertStageResponse(
        self,
        response: HttpResponse,
        flow: Flow | None = None,
        user: User | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """Assert various attributes of a stage response"""
        self.assertEqual(response.status_code, 200)
        raw_response = loads(response.content.decode())
        self.assertIsNotNone(raw_response["component"])
        if flow:
            self.assertIn("flow_info", raw_response)
            self.assertTrue(
                raw_response["flow_info"]["cancel_url"].startswith(
                    reverse("authentik_flows:cancel")
                )
            )
            # We don't check the flow title since it will most likely go
            # through ChallengeStageView.format_title() so might not match 1:1
            # self.assertEqual(raw_response["flow_info"]["title"], flow.title)
            self.assertIsNotNone(raw_response["flow_info"]["title"])
        if user:
            self.assertEqual(raw_response["pending_user"], user.username)
            self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
        for key, expected in kwargs.items():
>           self.assertEqual(raw_response[key], expected)

.../flows/tests/__init__.py:48: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertEqual(self, first, second, msg=None):
        """Fail if the two objects are unequal as determined by the '=='
           operator.
        """
        assertion_func = self._getAssertEqualityFunc(first, second)
>       assertion_func(first, second, msg=msg)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:925: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertMultiLineEqual(self, first, second, msg=None):
        """Assert that two multi-line strings are equal."""
        self.assertIsInstance(first, str, "First argument is not a string")
        self.assertIsInstance(second, str, "Second argument is not a string")
    
        if first != second:
            # Don't use difflib if the strings are too long
            if (len(first) > self._diffThreshold or
                len(second) > self._diffThreshold):
                self._baseAssertEqual(first, second, msg)
    
            # Append \n to both strings if either is missing the \n.
            # This allows the final ndiff to show the \n difference. The
            # exception here is if the string is empty, in which case no
            # \n should be added
            first_presplit = first
            second_presplit = second
            if first and second:
                if first[-1] != '\n' or second[-1] != '\n':
                    first_presplit += '\n'
                    second_presplit += '\n'
            elif second and second[-1] != '\n':
                second_presplit += '\n'
            elif first and first[-1] != '\n':
                first_presplit += '\n'
    
            firstlines = first_presplit.splitlines(keepends=True)
            secondlines = second_presplit.splitlines(keepends=True)
    
            # Generate the message and diff, then raise the exception
            standardMsg = '%s != %s' % _common_shorten_repr(first, second)
            diff = '\n' + ''.join(difflib.ndiff(firstlines, secondlines))
            standardMsg = self._truncateMessage(standardMsg, diff)
>           self.fail(self._formatMessage(msg, standardMsg))

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:1291: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_brand_ca>
msg = "'ak-stage-access-denied' != 'xak-flow-redirect'\n- ak-stage-access-denied\n+ xak-flow-redirect\n"

    def fail(self, msg=None):
        """Fail immediately, with the given message."""
>       raise self.failureException(msg)
E       AssertionError: 'ak-stage-access-denied' != 'xak-flow-redirect'
E       - ak-stage-access-denied
E       + xak-flow-redirect

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:750: AssertionError
authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests::test_parse_traefik
Stack Traces | 0.724s run time
self = <unittest.case._Outcome object at 0x7f1bafff9940>
test_case = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>
result = <TestCaseFunction test_parse_traefik>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
>                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:669: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>
method = <bound method MTLSStageTests.test_parse_traefik of <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>>

    def _callTestMethod(self, method):
>       result = method()
                 ^^^^^^^^

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:615: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>

    def test_parse_traefik(self):
        """Test traefik's format"""
        with self.assertFlowFinishes() as plan:
            res = self.client.get(
                reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
                headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
            )
            self.assertEqual(res.status_code, 200)
>           self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))

.../mtls/tests/test_stage.py:90: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>
response = <HttpChallengeResponse status_code=200, "application/json">, to = '/'

    def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
        """Wrapper around assertStageResponse that checks for a redirect"""
>       return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.../flows/tests/__init__.py:61: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>
response = <HttpChallengeResponse status_code=200, "application/json">
flow = None, user = None, kwargs = {'component': 'xak-flow-redirect', 'to': '/'}
raw_response = {'component': 'ak-stage-access-denied', 'error_message': 'Certificate required but no certificate was given.', 'flow_i..., 'background_themed_urls': None, 'cancel_url': '.../flows/-/cancel/', 'layout': 'stacked', ...}, 'pending_user': '', ...}
key = 'component', expected = 'xak-flow-redirect'

    def assertStageResponse(
        self,
        response: HttpResponse,
        flow: Flow | None = None,
        user: User | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """Assert various attributes of a stage response"""
        self.assertEqual(response.status_code, 200)
        raw_response = loads(response.content.decode())
        self.assertIsNotNone(raw_response["component"])
        if flow:
            self.assertIn("flow_info", raw_response)
            self.assertTrue(
                raw_response["flow_info"]["cancel_url"].startswith(
                    reverse("authentik_flows:cancel")
                )
            )
            # We don't check the flow title since it will most likely go
            # through ChallengeStageView.format_title() so might not match 1:1
            # self.assertEqual(raw_response["flow_info"]["title"], flow.title)
            self.assertIsNotNone(raw_response["flow_info"]["title"])
        if user:
            self.assertEqual(raw_response["pending_user"], user.username)
            self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
        for key, expected in kwargs.items():
>           self.assertEqual(raw_response[key], expected)

.../flows/tests/__init__.py:48: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertEqual(self, first, second, msg=None):
        """Fail if the two objects are unequal as determined by the '=='
           operator.
        """
        assertion_func = self._getAssertEqualityFunc(first, second)
>       assertion_func(first, second, msg=msg)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:925: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertMultiLineEqual(self, first, second, msg=None):
        """Assert that two multi-line strings are equal."""
        self.assertIsInstance(first, str, "First argument is not a string")
        self.assertIsInstance(second, str, "Second argument is not a string")
    
        if first != second:
            # Don't use difflib if the strings are too long
            if (len(first) > self._diffThreshold or
                len(second) > self._diffThreshold):
                self._baseAssertEqual(first, second, msg)
    
            # Append \n to both strings if either is missing the \n.
            # This allows the final ndiff to show the \n difference. The
            # exception here is if the string is empty, in which case no
            # \n should be added
            first_presplit = first
            second_presplit = second
            if first and second:
                if first[-1] != '\n' or second[-1] != '\n':
                    first_presplit += '\n'
                    second_presplit += '\n'
            elif second and second[-1] != '\n':
                second_presplit += '\n'
            elif first and first[-1] != '\n':
                first_presplit += '\n'
    
            firstlines = first_presplit.splitlines(keepends=True)
            secondlines = second_presplit.splitlines(keepends=True)
    
            # Generate the message and diff, then raise the exception
            standardMsg = '%s != %s' % _common_shorten_repr(first, second)
            diff = '\n' + ''.join(difflib.ndiff(firstlines, secondlines))
            standardMsg = self._truncateMessage(standardMsg, diff)
>           self.fail(self._formatMessage(msg, standardMsg))

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:1291: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_traefik>
msg = "'ak-stage-access-denied' != 'xak-flow-redirect'\n- ak-stage-access-denied\n+ xak-flow-redirect\n"

    def fail(self, msg=None):
        """Fail immediately, with the given message."""
>       raise self.failureException(msg)
E       AssertionError: 'ak-stage-access-denied' != 'xak-flow-redirect'
E       - ak-stage-access-denied
E       + xak-flow-redirect

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:750: AssertionError
authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests::test_parse_outpost_object
Stack Traces | 0.923s run time
self = <unittest.case._Outcome object at 0x7f1bb0562190>
test_case = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>
result = <TestCaseFunction test_parse_outpost_object>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
>                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:669: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>
method = <bound method MTLSStageTests.test_parse_outpost_object of <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>>

    def _callTestMethod(self, method):
>       result = method()
                 ^^^^^^^^

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:615: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>

    def test_parse_outpost_object(self):
        """Test outposts's format"""
        outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
        outpost.user.assign_perms_to_managed_role(
            "authentik_stages_mtls.pass_outpost_certificate", self.stage
        )
        with patch(
            "authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
            MagicMock(return_value=outpost.user),
        ):
            with self.assertFlowFinishes() as plan:
                res = self.client.get(
                    reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
                    headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)},
                )
                self.assertEqual(res.status_code, 200)
>               self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))

.../mtls/tests/test_stage.py:109: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>
response = <HttpChallengeResponse status_code=200, "application/json">, to = '/'

    def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
        """Wrapper around assertStageResponse that checks for a redirect"""
>       return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.../flows/tests/__init__.py:61: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>
response = <HttpChallengeResponse status_code=200, "application/json">
flow = None, user = None, kwargs = {'component': 'xak-flow-redirect', 'to': '/'}
raw_response = {'component': 'ak-stage-access-denied', 'error_message': 'Certificate required but no certificate was given.', 'flow_i..., 'background_themed_urls': None, 'cancel_url': '.../flows/-/cancel/', 'layout': 'stacked', ...}, 'pending_user': '', ...}
key = 'component', expected = 'xak-flow-redirect'

    def assertStageResponse(
        self,
        response: HttpResponse,
        flow: Flow | None = None,
        user: User | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """Assert various attributes of a stage response"""
        self.assertEqual(response.status_code, 200)
        raw_response = loads(response.content.decode())
        self.assertIsNotNone(raw_response["component"])
        if flow:
            self.assertIn("flow_info", raw_response)
            self.assertTrue(
                raw_response["flow_info"]["cancel_url"].startswith(
                    reverse("authentik_flows:cancel")
                )
            )
            # We don't check the flow title since it will most likely go
            # through ChallengeStageView.format_title() so might not match 1:1
            # self.assertEqual(raw_response["flow_info"]["title"], flow.title)
            self.assertIsNotNone(raw_response["flow_info"]["title"])
        if user:
            self.assertEqual(raw_response["pending_user"], user.username)
            self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
        for key, expected in kwargs.items():
>           self.assertEqual(raw_response[key], expected)

.../flows/tests/__init__.py:48: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertEqual(self, first, second, msg=None):
        """Fail if the two objects are unequal as determined by the '=='
           operator.
        """
        assertion_func = self._getAssertEqualityFunc(first, second)
>       assertion_func(first, second, msg=msg)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:925: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertMultiLineEqual(self, first, second, msg=None):
        """Assert that two multi-line strings are equal."""
        self.assertIsInstance(first, str, "First argument is not a string")
        self.assertIsInstance(second, str, "Second argument is not a string")
    
        if first != second:
            # Don't use difflib if the strings are too long
            if (len(first) > self._diffThreshold or
                len(second) > self._diffThreshold):
                self._baseAssertEqual(first, second, msg)
    
            # Append \n to both strings if either is missing the \n.
            # This allows the final ndiff to show the \n difference. The
            # exception here is if the string is empty, in which case no
            # \n should be added
            first_presplit = first
            second_presplit = second
            if first and second:
                if first[-1] != '\n' or second[-1] != '\n':
                    first_presplit += '\n'
                    second_presplit += '\n'
            elif second and second[-1] != '\n':
                second_presplit += '\n'
            elif first and first[-1] != '\n':
                first_presplit += '\n'
    
            firstlines = first_presplit.splitlines(keepends=True)
            secondlines = second_presplit.splitlines(keepends=True)
    
            # Generate the message and diff, then raise the exception
            standardMsg = '%s != %s' % _common_shorten_repr(first, second)
            diff = '\n' + ''.join(difflib.ndiff(firstlines, secondlines))
            standardMsg = self._truncateMessage(standardMsg, diff)
>           self.fail(self._formatMessage(msg, standardMsg))

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:1291: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_object>
msg = "'ak-stage-access-denied' != 'xak-flow-redirect'\n- ak-stage-access-denied\n+ xak-flow-redirect\n"

    def fail(self, msg=None):
        """Fail immediately, with the given message."""
>       raise self.failureException(msg)
E       AssertionError: 'ak-stage-access-denied' != 'xak-flow-redirect'
E       - ak-stage-access-denied
E       + xak-flow-redirect

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:750: AssertionError
authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests::test_parse_outpost_global
Stack Traces | 0.96s run time
self = <unittest.case._Outcome object at 0x7f1bb02e81a0>
test_case = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>
result = <TestCaseFunction test_parse_outpost_global>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
>                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:669: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>
method = <bound method MTLSStageTests.test_parse_outpost_global of <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>>

    def _callTestMethod(self, method):
>       result = method()
                 ^^^^^^^^

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:615: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>

    def test_parse_outpost_global(self):
        """Test outposts's format"""
        outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
        outpost.user.assign_perms_to_managed_role("authentik_stages_mtls.pass_outpost_certificate")
        with patch(
            "authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
            MagicMock(return_value=outpost.user),
        ):
            with self.assertFlowFinishes() as plan:
                res = self.client.get(
                    reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
                    headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)},
                )
                self.assertEqual(res.status_code, 200)
>               self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))

.../mtls/tests/test_stage.py:126: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>
response = <HttpChallengeResponse status_code=200, "application/json">, to = '/'

    def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
        """Wrapper around assertStageResponse that checks for a redirect"""
>       return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.../flows/tests/__init__.py:61: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>
response = <HttpChallengeResponse status_code=200, "application/json">
flow = None, user = None, kwargs = {'component': 'xak-flow-redirect', 'to': '/'}
raw_response = {'component': 'ak-stage-access-denied', 'error_message': 'Certificate required but no certificate was given.', 'flow_i..., 'background_themed_urls': None, 'cancel_url': '.../flows/-/cancel/', 'layout': 'stacked', ...}, 'pending_user': '', ...}
key = 'component', expected = 'xak-flow-redirect'

    def assertStageResponse(
        self,
        response: HttpResponse,
        flow: Flow | None = None,
        user: User | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """Assert various attributes of a stage response"""
        self.assertEqual(response.status_code, 200)
        raw_response = loads(response.content.decode())
        self.assertIsNotNone(raw_response["component"])
        if flow:
            self.assertIn("flow_info", raw_response)
            self.assertTrue(
                raw_response["flow_info"]["cancel_url"].startswith(
                    reverse("authentik_flows:cancel")
                )
            )
            # We don't check the flow title since it will most likely go
            # through ChallengeStageView.format_title() so might not match 1:1
            # self.assertEqual(raw_response["flow_info"]["title"], flow.title)
            self.assertIsNotNone(raw_response["flow_info"]["title"])
        if user:
            self.assertEqual(raw_response["pending_user"], user.username)
            self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
        for key, expected in kwargs.items():
>           self.assertEqual(raw_response[key], expected)

.../flows/tests/__init__.py:48: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertEqual(self, first, second, msg=None):
        """Fail if the two objects are unequal as determined by the '=='
           operator.
        """
        assertion_func = self._getAssertEqualityFunc(first, second)
>       assertion_func(first, second, msg=msg)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:925: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertMultiLineEqual(self, first, second, msg=None):
        """Assert that two multi-line strings are equal."""
        self.assertIsInstance(first, str, "First argument is not a string")
        self.assertIsInstance(second, str, "Second argument is not a string")
    
        if first != second:
            # Don't use difflib if the strings are too long
            if (len(first) > self._diffThreshold or
                len(second) > self._diffThreshold):
                self._baseAssertEqual(first, second, msg)
    
            # Append \n to both strings if either is missing the \n.
            # This allows the final ndiff to show the \n difference. The
            # exception here is if the string is empty, in which case no
            # \n should be added
            first_presplit = first
            second_presplit = second
            if first and second:
                if first[-1] != '\n' or second[-1] != '\n':
                    first_presplit += '\n'
                    second_presplit += '\n'
            elif second and second[-1] != '\n':
                second_presplit += '\n'
            elif first and first[-1] != '\n':
                first_presplit += '\n'
    
            firstlines = first_presplit.splitlines(keepends=True)
            secondlines = second_presplit.splitlines(keepends=True)
    
            # Generate the message and diff, then raise the exception
            standardMsg = '%s != %s' % _common_shorten_repr(first, second)
            diff = '\n' + ''.join(difflib.ndiff(firstlines, secondlines))
            standardMsg = self._truncateMessage(standardMsg, diff)
>           self.fail(self._formatMessage(msg, standardMsg))

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:1291: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_outpost_global>
msg = "'ak-stage-access-denied' != 'xak-flow-redirect'\n- ak-stage-access-denied\n+ xak-flow-redirect\n"

    def fail(self, msg=None):
        """Fail immediately, with the given message."""
>       raise self.failureException(msg)
E       AssertionError: 'ak-stage-access-denied' != 'xak-flow-redirect'
E       - ak-stage-access-denied
E       + xak-flow-redirect

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:750: AssertionError
authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests::test_parse_xfcc
Stack Traces | 1.15s run time
self = <unittest.case._Outcome object at 0x7f1bb0480830>
test_case = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
>           yield

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>
result = <TestCaseFunction test_parse_xfcc>

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
>                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:669: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>
method = <bound method MTLSStageTests.test_parse_xfcc of <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>>

    def _callTestMethod(self, method):
>       result = method()
                 ^^^^^^^^

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:615: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>

    def test_parse_xfcc(self):
        """Test authentik Proxy/Envoy's XFCC format"""
        with self.assertFlowFinishes() as plan:
            res = self.client.get(
                reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
                headers={"X-Forwarded-Client-Cert": f"Cert={quote_plus(self.client_cert)}"},
            )
            self.assertEqual(res.status_code, 200)
>           self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))

.../mtls/tests/test_stage.py:68: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>
response = <HttpChallengeResponse status_code=200, "application/json">, to = '/'

    def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
        """Wrapper around assertStageResponse that checks for a redirect"""
>       return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.../flows/tests/__init__.py:61: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>
response = <HttpChallengeResponse status_code=200, "application/json">
flow = None, user = None, kwargs = {'component': 'xak-flow-redirect', 'to': '/'}
raw_response = {'component': 'ak-stage-access-denied', 'error_message': 'Certificate required but no certificate was given.', 'flow_i..., 'background_themed_urls': None, 'cancel_url': '.../flows/-/cancel/', 'layout': 'stacked', ...}, 'pending_user': '', ...}
key = 'component', expected = 'xak-flow-redirect'

    def assertStageResponse(
        self,
        response: HttpResponse,
        flow: Flow | None = None,
        user: User | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """Assert various attributes of a stage response"""
        self.assertEqual(response.status_code, 200)
        raw_response = loads(response.content.decode())
        self.assertIsNotNone(raw_response["component"])
        if flow:
            self.assertIn("flow_info", raw_response)
            self.assertTrue(
                raw_response["flow_info"]["cancel_url"].startswith(
                    reverse("authentik_flows:cancel")
                )
            )
            # We don't check the flow title since it will most likely go
            # through ChallengeStageView.format_title() so might not match 1:1
            # self.assertEqual(raw_response["flow_info"]["title"], flow.title)
            self.assertIsNotNone(raw_response["flow_info"]["title"])
        if user:
            self.assertEqual(raw_response["pending_user"], user.username)
            self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
        for key, expected in kwargs.items():
>           self.assertEqual(raw_response[key], expected)

.../flows/tests/__init__.py:48: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertEqual(self, first, second, msg=None):
        """Fail if the two objects are unequal as determined by the '=='
           operator.
        """
        assertion_func = self._getAssertEqualityFunc(first, second)
>       assertion_func(first, second, msg=msg)

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:925: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>
first = 'ak-stage-access-denied', second = 'xak-flow-redirect', msg = None

    def assertMultiLineEqual(self, first, second, msg=None):
        """Assert that two multi-line strings are equal."""
        self.assertIsInstance(first, str, "First argument is not a string")
        self.assertIsInstance(second, str, "Second argument is not a string")
    
        if first != second:
            # Don't use difflib if the strings are too long
            if (len(first) > self._diffThreshold or
                len(second) > self._diffThreshold):
                self._baseAssertEqual(first, second, msg)
    
            # Append \n to both strings if either is missing the \n.
            # This allows the final ndiff to show the \n difference. The
            # exception here is if the string is empty, in which case no
            # \n should be added
            first_presplit = first
            second_presplit = second
            if first and second:
                if first[-1] != '\n' or second[-1] != '\n':
                    first_presplit += '\n'
                    second_presplit += '\n'
            elif second and second[-1] != '\n':
                second_presplit += '\n'
            elif first and first[-1] != '\n':
                first_presplit += '\n'
    
            firstlines = first_presplit.splitlines(keepends=True)
            secondlines = second_presplit.splitlines(keepends=True)
    
            # Generate the message and diff, then raise the exception
            standardMsg = '%s != %s' % _common_shorten_repr(first, second)
            diff = '\n' + ''.join(difflib.ndiff(firstlines, secondlines))
            standardMsg = self._truncateMessage(standardMsg, diff)
>           self.fail(self._formatMessage(msg, standardMsg))

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:1291: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <authentik.enterprise.stages.mtls.tests.test_stage.MTLSStageTests testMethod=test_parse_xfcc>
msg = "'ak-stage-access-denied' != 'xak-flow-redirect'\n- ak-stage-access-denied\n+ xak-flow-redirect\n"

    def fail(self, msg=None):
        """Fail immediately, with the given message."""
>       raise self.failureException(msg)
E       AssertionError: 'ak-stage-access-denied' != 'xak-flow-redirect'
E       - ak-stage-access-denied
E       + xak-flow-redirect

.../hostedtoolcache/Python/3.14.5................../x64/lib/python3.14/unittest/case.py:750: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 19, 2026

Deploy Preview for authentik-docs ready!

Name Link
🔨 Latest commit c4d8daf
🔍 Latest deploy log https://app.netlify.com/projects/authentik-docs/deploys/6a0c4d0e1726c9000882cece
😎 Deploy Preview https://deploy-preview-22465--authentik-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Wraps the build phase of the npm publish workflow (`tsc -p .`, plus
`typedoc` for `packages/esbuild-plugin-live-reload`) in Node 24's
permission model.

Configuration:

  node --permission \
       --allow-fs-read="$PWD" \
       --allow-fs-write="$PWD" \
       ./node_modules/typescript/bin/tsc -p .

`--permission` with no other `--allow` flags denies by default:
  - network (--allow-net)
  - child_process (--allow-child-process)
  - worker_threads (--allow-worker)
  - native addons
  - WASI

fs reads and writes are scoped to the package working tree. The
threat model addressed: a malicious devDep (direct or transitive)
that wakes up during `tsc` and tries to exfil credentials. It can
still read process.env, but with no network, no subprocess, and no
fs write outside `$PWD`, it has no channel to send anything out.

`npm ci` and `npm publish` keep their full capability set — both
legitimately need network and subprocess access, and `npm publish`'s
defense lives at the runner egress layer (step-security/harden-runner,
added in #22463).

This is the smallest tractable surface for the permission model in
this repo: all six publishable packages build via `tsc` (one also
runs `typedoc`), with no native addons and no in-build child
processes. If a future package needs broader permissions, the
allow-list can be widened per-step rather than discarded.

The Node permission model is still in active development (stability
1.1 in v24); the API surface is stable enough to commit to but the
"experiment" framing is intentional — if a downstream tsc/typedoc
revision starts touching paths outside `$PWD`, revert is one-line.

Co-authored-by: Agent <279763771+playpen-agent@users.noreply.github.com>
@GirlBossRush GirlBossRush force-pushed the ci/node-permission-publish-build branch from c4d8daf to d29a2d0 Compare May 20, 2026 02:09
@rissson rissson requested a review from BeryJu May 20, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant