From 943394237818e3bc7ac1d9ceb7ef9c7d41d2fa52 Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Tue, 8 Sep 2026 14:01:38 +0200 Subject: [PATCH 01/24] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-09-08: - update .custodian-brief.md for vergabe-teilnahme Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- .custodian-brief.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 05958b6..3dd5307 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,22 +2,17 @@ # Custodian Brief — vergabe-teilnahme **Domain:** communication -**Last synced:** 2026-08-26 18:23 UTC +**Last synced:** 2026-09-08 12:01 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams -*(none — repo may need first-session setup)* +### Establish verified delivery for the primary customer tender product +Progress: 1/3 done | workplan_id: `27a95f7b-cec4-50ca-8383-c1c95d996217` -## Inbox Hygiene - -**Stale unread:** 1 message(s) older than 3 day(s) — triage at session start. -**Missing thread_id:** 1 unread message(s) lack supersession chains. -- ! state-hub: [BREAKING] Task status canon: wait/todo/progress/done/cancel `d5ea5add` - -## SCOPE Freshness - -- SCOPE.md says active but no open workplans remain — refresh Current State. +**Open tasks:** +- ! Accept the first Railiance customer pilot release `6e428152` +- ► Gate releases with useful application acceptance `dc889dba` --- ## MCP Orientation (when available) From 1e5b7e8298a60adba0386c7f320614ee7d007ec4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 8 Sep 2026 14:07:46 +0200 Subject: [PATCH 02/24] Record verified customer delivery and isolate the remaining deployment gate Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- ...RGABE-WP-0018-customer-factory-delivery.md | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/workplans/VERGABE-WP-0018-customer-factory-delivery.md b/workplans/VERGABE-WP-0018-customer-factory-delivery.md index e7b718a..b5a74be 100644 --- a/workplans/VERGABE-WP-0018-customer-factory-delivery.md +++ b/workplans/VERGABE-WP-0018-customer-factory-delivery.md @@ -4,12 +4,13 @@ type: workplan title: "Establish verified delivery for the primary customer tender product" domain: communication repo: vergabe-teilnahme -status: active +status: blocked owner: the-custodian topic_slug: vergabe-teilnahme created: "2026-09-08" updated: "2026-09-08" related: [HFACT-WP-0001, REUSE-WP-0022] +state_hub_workstream_id: "27a95f7b-cec4-50ca-8383-c1c95d996217" --- # Customer product delivery @@ -28,6 +29,7 @@ id: VERGABE-WP-0018-T01 status: done priority: high assignee: the-custodian +state_hub_task_id: "ec53efce-ba3c-5965-9c64-ea0480bfae68" ``` Update product entry points and classification to reflect the user's explicit @@ -40,9 +42,10 @@ score requires owner review; it does not authorize a duplicate capability. ```task id: VERGABE-WP-0018-T02 -status: progress +status: done priority: high assignee: the-custodian +state_hub_task_id: "dc889dba-3e10-5017-af9f-4843119a103a" ``` Run existing application tests against disposable data, including tender create, @@ -59,7 +62,8 @@ status: wait priority: high assignee: the-custodian depends_on: [VERGABE-WP-0018-T02, HFACT-WP-0001-T05] -blocking_reason: "Await tested/reviewed image and exact Railiance tenant, deployment, database, storage, access and recovery owner acceptance." +blocking_reason: "Image main-fa9f082 is tested and published. Await HFACT-WP-0001-T05 governed worker evidence plus exact Railiance tenant, deployment, database, storage, access and recovery acceptance." +state_hub_task_id: "6e428152-aad6-5184-8b1d-8860ec0b9ae6" ``` Coordinator: the-custodian; product acceptance: Bernd Worsch; deployment owner: @@ -84,7 +88,7 @@ Return the release and consumer evidence to HFACT-WP-0001-T06/T07. 65-capability provenance and review disposition are in `docs/evidence/2026-09-08-hosted-plan-check.json`. -T02 remains in progress until the published exact revision has its CI return. +T02 is complete with the live CI and immutable image receipts below. T03 retains the concrete deployment/data/access/recovery gates. Attended Codex source work is not a natural governed worker claim/heartbeat/close trace. @@ -92,3 +96,14 @@ Live CI runs 15/16 exposed the runner's missing Buildx/BuildKit support, despite local acceptance. Added pinned temporary CLI setup shared by application and image workflows, and removed duplicate feature-push test scheduling. The next exact PR revision must pass on the actual runner before T02 closes. + +## Published return + +PR 1 source at `fa9f08268a5669d3832753929bb5a2ad267bfa72` passed live +application CI 17 and was integrated on main. Main application CI 18 and image +publication 20 passed. Published image `main-fa9f082` has digest +`sha256:cb48658f5bfeeef91b8e16de94e8833be01a68bf72619567665c9f7fe927d062`. +T01/T02 are done; the workplan is blocked on T03's admitted customer release. +HFACT-WP-0001 consumes the source and artifact receipts without claiming a +current governed worker run. Forgejo PR 1 metadata needs its authenticated +`manually-merged` receipt; source integration itself is verified on main. From f04880b973b5ae416999aa2d961ffa7db74dd7e8 Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Tue, 8 Sep 2026 14:19:31 +0200 Subject: [PATCH 03/24] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-09-08: - update .custodian-brief.md for vergabe-teilnahme Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- .custodian-brief.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 3dd5307..2b55c19 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,17 +2,16 @@ # Custodian Brief — vergabe-teilnahme **Domain:** communication -**Last synced:** 2026-09-08 12:01 UTC +**Last synced:** 2026-09-08 12:19 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Establish verified delivery for the primary customer tender product -Progress: 1/3 done | workplan_id: `27a95f7b-cec4-50ca-8383-c1c95d996217` +Progress: 2/3 done | workplan_id: `27a95f7b-cec4-50ca-8383-c1c95d996217` **Open tasks:** - ! Accept the first Railiance customer pilot release `6e428152` -- ► Gate releases with useful application acceptance `dc889dba` --- ## MCP Orientation (when available) From 56bf193193a4db635692ddd9e534c233b13f904e Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 8 Sep 2026 14:21:22 +0200 Subject: [PATCH 04/24] Refresh customer delivery work-record index Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- WORK-RECORDS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f942cfb..af1695e 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,6 +8,7 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | +| workplan | VERGABE-WP-0018 | blocked | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | | workplan | VT-WP-0001 | finished | — | workplans/VT-WP-0001-projektgeruest.md | | workplan | VT-WP-0002 | finished | — | workplans/VT-WP-0002-fachmodelle.md | | workplan | VT-WP-0003 | finished | — | workplans/VT-WP-0003-basis-ui.md | @@ -25,6 +26,9 @@ | workplan | VT-WP-0015 | finished | — | workplans/VT-WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md | | workplan | VT-WP-0016 | finished | — | workplans/VT-WP-0016-issue-facade-integration.md | | workplan | VT-WP-0017 | finished | — | workplans/VT-WP-0017-whynot-design-tokens.md | +| task | VERGABE-WP-0018-T01 | done | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | +| task | VERGABE-WP-0018-T02 | done | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | +| task | VERGABE-WP-0018-T03 | wait | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | | task | VT-WP-0001-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T05 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T07 | done | — | workplans/VT-WP-0001-projektgeruest.md | From b7d7828f30cb30ef6bb37e48886dee3be3752e32 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 16:35:13 +0200 Subject: [PATCH 05/24] Require invited login and private downloads for the company pilot Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- SCOPE.md | 15 +- conftest.py | 7 + docs/delivery.md | 25 ++++ .../2026-09-11-invited-pilot-access.json | 42 ++++++ vergabe_teilnahme/apps/accounts/forms.py | 16 ++ vergabe_teilnahme/apps/accounts/middleware.py | 21 +++ vergabe_teilnahme/apps/accounts/tests.py | 139 +++++++++++++++++- vergabe_teilnahme/apps/accounts/urls.py | 21 +++ vergabe_teilnahme/apps/aufgaben/tests.py | 52 +++---- .../apps/ausschreibungen/tests.py | 24 +-- vergabe_teilnahme/apps/bibliothek/tests.py | 16 +- vergabe_teilnahme/apps/dokumente/downloads.py | 23 +++ vergabe_teilnahme/apps/dokumente/tests.py | 20 +-- vergabe_teilnahme/apps/lose/tests.py | 28 ++-- .../apps/marktbegleiter/tests.py | 12 +- .../apps/nachbetrachtung/tests.py | 12 +- vergabe_teilnahme/apps/partner/tests.py | 16 +- vergabe_teilnahme/apps/preise/tests.py | 12 +- vergabe_teilnahme/settings/base.py | 4 + .../templates/accounts/base.html | 16 ++ .../templates/accounts/login.html | 13 ++ .../templates/accounts/password_change.html | 11 ++ .../accounts/password_change_done.html | 7 + .../templates/partials/topbar.html | 8 +- vergabe_teilnahme/urls.py | 10 +- ...RGABE-WP-0018-customer-factory-delivery.md | 16 +- .../VERGABE-WP-0019-invited-company-pilot.md | 109 ++++++++++++++ 27 files changed, 584 insertions(+), 111 deletions(-) create mode 100644 docs/evidence/2026-09-11-invited-pilot-access.json create mode 100644 vergabe_teilnahme/apps/accounts/forms.py create mode 100644 vergabe_teilnahme/apps/accounts/middleware.py create mode 100644 vergabe_teilnahme/apps/accounts/urls.py create mode 100644 vergabe_teilnahme/apps/dokumente/downloads.py create mode 100644 vergabe_teilnahme/templates/accounts/base.html create mode 100644 vergabe_teilnahme/templates/accounts/login.html create mode 100644 vergabe_teilnahme/templates/accounts/password_change.html create mode 100644 vergabe_teilnahme/templates/accounts/password_change_done.html create mode 100644 workplans/VERGABE-WP-0019-invited-company-pilot.md diff --git a/SCOPE.md b/SCOPE.md index 33739a6..31c7c85 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -41,7 +41,7 @@ It operates on three levels, per `wiki/ProductRequirementsDocument.md`: Primary customer service/UI repository for the HelixForge factory, selected by the user. Initial delivery preserves a single-company boundary; customer release requires explicit deployment, access, persistence, restore and support acceptance -in VERGABE-WP-0018. The original internal collaboration workflows remain valid. +in VERGABE-WP-0019 (invited pilot) and VERGABE-WP-0018 (factory delivery). The original internal collaboration workflows remain valid. ## In Scope @@ -60,7 +60,7 @@ in VERGABE-WP-0018. The original internal collaboration workflows remain valid. - External user accounts for partners/subcontractors/service providers (data objects only in this build stage, no system access of their own) -- Multi-tenancy +- Shared-application multi-tenancy; invited companies use isolated deployments - Automated ingestion from tender platforms, SharePoint, Teams, CRM, ERP, email, or calendars (deliberately manual-entry-first for v1) - Certification or legal validity of submissions — the system tracks @@ -95,7 +95,7 @@ in VERGABE-WP-0018. The original internal collaboration workflows remain valid. feedback, lose, marktbegleiter, nachbetrachtung, partner, preise); 17 workplans (WP-0001–WP-0017) implemented in sequence from project scaffold through whynot-design token adoption -- Stability: evolving — manual-entry-first v1; no CI workflow configured yet +- Stability: evolving — manual-entry-first v1; Forgejo gates images with application/container acceptance (`.gitea/`/`.forgejo/` absent) - Usage: internal collaboration tool, not yet published for external use @@ -159,3 +159,12 @@ Product and architecture documentation (`wiki/`) is in German; this SCOPE.md is in English per the reuse-surface registry's Markdown-first, agent-facing convention. Refer to `wiki/ProductRequirementsDocument.md` for the authoritative German-language product definition. + + +## Invited-pilot milestone — 2026-09-11 + +One company, several manually onboarded users; pricing follows later by user +decision. Django login and protected downloads are source-tested. Native release, +data recovery, user acceptance and support remain in VERGABE-WP-0019 and +RAPPS-WP-0014. Automated source/container acceptance already exists in Forgejo; +older current-state statements about missing CI are historical. diff --git a/conftest.py b/conftest.py index 709e1cc..1c0f5cd 100644 --- a/conftest.py +++ b/conftest.py @@ -8,3 +8,10 @@ def mitarbeiter(db): return Mitarbeiter.objects.create_user( username='testuser', password='testpass', first_name='Test', last_name='User' ) + + +@pytest.fixture +def admitted_client(client, mitarbeiter): + """Domain workflow tests act as a member; the standard client stays anonymous.""" + client.force_login(mitarbeiter) + return client diff --git a/docs/delivery.md b/docs/delivery.md index d14a477..6a47d16 100644 --- a/docs/delivery.md +++ b/docs/delivery.md @@ -58,3 +58,28 @@ digest and chart revision, tenant/host and login binding, database and media ownership, backup/restore and rollback, UI smoke and named operating owner. Existing production tender data is never disposable test data. A passing source build does not establish customer availability or the governed factory worker. + + +## Invited pilot — 2026-09-11 + +[VERGABE-WP-0019](../workplans/VERGABE-WP-0019-invited-company-pilot.md) +advances one manually onboarded company alongside factory admission. Billing +and a shared tenancy implementation are not pilot gates. All application routes +and media downloads require an active Django account; health and login remain +public. Members can change passwords; administrators create/reset/deactivate +accounts using the existing Django administration through the admitted operator +path. There is no public sign-up or email invitation delivery in this milestone. + +Use the Railiance Apps pilot contract in `docs/vergabe-teilnahme-pilot.md` and +RAPPS-WP-0014. Record the newly tested image digest; the older published image +has no global access gate. Persist PostgreSQL, uploads and issue-facade SQLite +separately, and prove coherent restore before admitting customer data. Serve +`/media/` through Django, never directly through an unauthenticated ingress or +static file server. The image's `/app/.issue-facade` directory is operational +state and must never be placed below MEDIA_ROOT. + +Application tests use the standard anonymous `client` for access tests and the +explicit `admitted_client` fixture for company workflows. Existing end-to-end +tests retain their explicit login. Container acceptance remains the release +gate; native two-user workflow, restored data, support and rollout evidence +remain open until performed on the admitted deployment. diff --git a/docs/evidence/2026-09-11-invited-pilot-access.json b/docs/evidence/2026-09-11-invited-pilot-access.json new file mode 100644 index 0000000..cd09e5f --- /dev/null +++ b/docs/evidence/2026-09-11-invited-pilot-access.json @@ -0,0 +1,42 @@ +{ + "schema": "vergabe.invited-pilot-source-acceptance.v1", + "date": "2026-09-11", + "source_base_commit": "56bf193193a4db635692ddd9e534c233b13f904e", + "regression_baseline": { + "failed": 9, + "passed": 3, + "anonymous_read_and_create_demonstrated": true + }, + "application_suite": { + "passed": 94, + "database": "disposable SQLite", + "local": true, + "container": true, + "container_image_id": "sha256:6f85d039115d5412b77a65740baa741f37e9ac2bf5f28a7a216aebf8f3c428cc", + "static_files_collected": 139, + "vite_build": "passed", + "migration_drift": false, + "warning": "One existing naive-datetime warning" + }, + "ruff_new_access_modules": "passed", + "browser": { + "status": "passed", + "checks": [ + "Anonymous tender page redirects to login", + "Password input has a visible border and usable height", + "Invited member logs in and reaches password-change UI", + "User menu submits CSRF-protected logout and returns to login", + "Login fits a 390-pixel viewport without horizontal overflow", + "No browser JavaScript errors" + ], + "synthetic_users": true, + "disposable_sqlite": true, + "local_http_only": true, + "native_customer_admission": false + }, + "native_customer_admission": false, + "live_customer_data_used": false, + "pricing_required_for_pilot": false, + "factory_attempts": 0, + "paid_model_calls": 0 +} diff --git a/vergabe_teilnahme/apps/accounts/forms.py b/vergabe_teilnahme/apps/accounts/forms.py new file mode 100644 index 0000000..7a612bd --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/forms.py @@ -0,0 +1,16 @@ +from django.contrib.auth import forms + + +class _AccountFieldStyle: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs['class'] = 'form-input' + + +class AuthenticationForm(_AccountFieldStyle, forms.AuthenticationForm): + pass + + +class PasswordChangeForm(_AccountFieldStyle, forms.PasswordChangeForm): + pass diff --git a/vergabe_teilnahme/apps/accounts/middleware.py b/vergabe_teilnahme/apps/accounts/middleware.py new file mode 100644 index 0000000..0aaa6ed --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/middleware.py @@ -0,0 +1,21 @@ +from django.contrib.auth.middleware import LoginRequiredMiddleware +from django.contrib.auth.views import redirect_to_login +from django.http import HttpResponse +from django.utils.cache import add_never_cache_headers + + +class PilotLoginRequiredMiddleware(LoginRequiredMiddleware): + """Use Django's default-deny gate, including full-page HTMX reauthentication.""" + + def handle_no_permission(self, request, view_func): + if request.headers.get('HX-Request') == 'true': + # The original URL may render only a fragment or accept only POST. + login = redirect_to_login('/', self.get_login_url(view_func)) + return HttpResponse(status=401, headers={'HX-Redirect': login.url}) + return super().handle_no_permission(request, view_func) + + def process_response(self, request, response): + # Company records and account pages must not survive in shared caches. + if request.path != '/health/': + add_never_cache_headers(response) + return response diff --git a/vergabe_teilnahme/apps/accounts/tests.py b/vergabe_teilnahme/apps/accounts/tests.py index 7ce503c..62416e2 100644 --- a/vergabe_teilnahme/apps/accounts/tests.py +++ b/vergabe_teilnahme/apps/accounts/tests.py @@ -1,3 +1,138 @@ -from django.test import TestCase +from urllib.parse import parse_qs, urlsplit -# Create your tests here. +import pytest +from django.test import Client + +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung + +pytestmark = pytest.mark.django_db + + +def test_anonymous_cannot_read_tenders(client): + Ausschreibung.objects.create(titel='Confidential tender', ausschreiber='Pilot') + response = client.get('/ausschreibungen/') + assert response.status_code == 302 + assert urlsplit(response.url).path == '/accounts/login/' + assert b'Confidential tender' not in response.content + + +def test_anonymous_cannot_create_tender(client): + response = client.post('/ausschreibungen/neu/', { + 'titel': 'Unauthorised tender', 'ausschreiber': 'Pilot', + }) + assert response.status_code == 302 + assert not Ausschreibung.objects.exists() + + +def test_expired_htmx_session_requires_full_login(client): + response = client.get('/suche/?q=private', HTTP_HX_REQUEST='true') + assert response.status_code == 401 + assert urlsplit(response['HX-Redirect']).path == '/accounts/login/' + # A fragment URL must never become the full page after login. + assert parse_qs(urlsplit(response['HX-Redirect']).query)['next'] == ['/'] + + +def test_health_is_public_and_contains_no_tenant_data(client): + assert client.get('/health/').json() == {'status': 'ok'} + + +def test_invited_login_redirect_and_logout(mitarbeiter): + client = Client(enforce_csrf_checks=True) + response = client.get('/accounts/login/?next=/ausschreibungen/') + assert response.status_code == 200 + assert b'Benutzername' in response.content + assert b'search-results' not in response.content + credentials = {'username': mitarbeiter.username, 'password': 'testpass', + 'next': '/ausschreibungen/'} + assert client.post('/accounts/login/', credentials).status_code == 403 + credentials['csrfmiddlewaretoken'] = client.cookies['csrftoken'].value + response = client.post('/accounts/login/', credentials) + assert response.status_code == 302 + assert response.url == '/ausschreibungen/' + assert client.get(response.url).status_code == 200 + assert client.get('/accounts/logout/').status_code == 405 + assert client.post('/accounts/logout/').status_code == 403 + response = client.post('/accounts/logout/', { + 'csrfmiddlewaretoken': client.cookies['csrftoken'].value, + }) + assert response.url == '/accounts/login/' + assert client.get('/ausschreibungen/').status_code == 302 + + +def test_login_rejects_external_next(client, mitarbeiter): + response = client.post('/accounts/login/', { + 'username': mitarbeiter.username, 'password': 'testpass', + 'next': 'https://untrusted.example/private', + }) + assert response.status_code == 302 + assert response.url == '/' + + +def test_deactivated_member_loses_existing_session_and_cannot_login(client, mitarbeiter): + client.force_login(mitarbeiter) + mitarbeiter.is_active = False + mitarbeiter.save(update_fields=['is_active']) + assert client.get('/ausschreibungen/').status_code == 302 + response = client.post('/accounts/login/', { + 'username': mitarbeiter.username, 'password': 'testpass', + }) + assert response.status_code == 200 + assert response.context['form'].errors + assert client.get('/ausschreibungen/').status_code == 302 + + +def test_member_password_change_keeps_session_and_replaces_password(client, mitarbeiter): + client.force_login(mitarbeiter) + assert client.get('/accounts/password-change/').status_code == 200 + response = client.post('/accounts/password-change/', { + 'old_password': 'testpass', + 'new_password1': 'A-unique-pilot-password-914!', + 'new_password2': 'A-unique-pilot-password-914!', + }) + assert response.status_code == 302 + assert client.get(response.url).status_code == 200 + assert client.get('/ausschreibungen/').status_code == 200 + mitarbeiter.refresh_from_db() + assert not mitarbeiter.check_password('testpass') + assert mitarbeiter.check_password('A-unique-pilot-password-914!') + + +def test_company_member_does_not_gain_django_admin_access(client, mitarbeiter): + client.force_login(mitarbeiter) + response = client.get('/admin/') + assert response.status_code == 302 + assert urlsplit(response.url).path == '/admin/login/' + + +@pytest.mark.parametrize('debug', [False, True]) +def test_media_requires_login_and_downloads_as_private_attachment( + client, mitarbeiter, tmp_path, settings, debug, +): + settings.DEBUG = debug + settings.MEDIA_ROOT = tmp_path / 'media' + settings.MEDIA_ROOT.mkdir() + (settings.MEDIA_ROOT / 'tender.pdf').write_bytes(b'%PDF-pilot') + assert client.get('/media/tender.pdf').status_code == 302 + client.force_login(mitarbeiter) + response = client.get('/media/tender.pdf') + assert response.status_code == 200 + assert b''.join(response.streaming_content) == b'%PDF-pilot' + assert response['Content-Disposition'] == 'attachment; filename="tender.pdf"' + assert response['X-Content-Type-Options'] == 'nosniff' + assert 'no-store' in response['Cache-Control'] + + +def test_media_rejects_traversal_symlink_directory_and_missing_file( + client, mitarbeiter, tmp_path, settings, +): + settings.MEDIA_ROOT = tmp_path / 'media' + settings.MEDIA_ROOT.mkdir() + private = tmp_path / 'issues.db' + private.write_bytes(b'private issue state') + (settings.MEDIA_ROOT / 'escape.pdf').symlink_to(private) + (settings.MEDIA_ROOT / 'folder').mkdir() + client.force_login(mitarbeiter) + for path in ('../issues.db', '%2e%2e/issues.db', 'escape.pdf', 'folder', 'absent.pdf'): + response = client.get('/media/' + path) + assert response.status_code == 404 + assert b'private issue state' not in response.content diff --git a/vergabe_teilnahme/apps/accounts/urls.py b/vergabe_teilnahme/apps/accounts/urls.py new file mode 100644 index 0000000..946e92c --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/urls.py @@ -0,0 +1,21 @@ +from django.contrib.auth import views +from django.urls import path, reverse_lazy + +from .forms import AuthenticationForm, PasswordChangeForm + +app_name = 'accounts' + +urlpatterns = [ + path('login/', views.LoginView.as_view( + template_name='accounts/login.html', authentication_form=AuthenticationForm, + ), name='login'), + path('logout/', views.LogoutView.as_view(), name='logout'), + path('password-change/', views.PasswordChangeView.as_view( + template_name='accounts/password_change.html', + form_class=PasswordChangeForm, + success_url=reverse_lazy('accounts:password_change_done'), + ), name='password_change'), + path('password-change/done/', views.PasswordChangeDoneView.as_view( + template_name='accounts/password_change_done.html', + ), name='password_change_done'), +] diff --git a/vergabe_teilnahme/apps/aufgaben/tests.py b/vergabe_teilnahme/apps/aufgaben/tests.py index 0a789e0..62de51a 100644 --- a/vergabe_teilnahme/apps/aufgaben/tests.py +++ b/vergabe_teilnahme/apps/aufgaben/tests.py @@ -33,40 +33,40 @@ class BieterfragenFactory(factory.django.DjangoModelFactory): @pytest.mark.django_db -def test_aufgaben_liste_get(client): +def test_aufgaben_liste_get(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 @pytest.mark.django_db -def test_aufgabe_neu_post(client): +def test_aufgabe_neu_post(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:aufgaben:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, {'titel': 'Neue Aufgabe', 'typ': 'fachlich', 'prioritaet': 2}) + response = admitted_client.post(url, {'titel': 'Neue Aufgabe', 'typ': 'fachlich', 'prioritaet': 2}) assert response.status_code == 302 assert Aufgabe.objects.filter(ausschreibung=a, titel='Neue Aufgabe').exists() @pytest.mark.django_db -def test_aufgabe_status_htmx(client): +def test_aufgabe_status_htmx(admitted_client): aufgabe = AufgabeFactory() url = reverse('ausschreibungen:aufgaben:status', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = client.post(url, {'status': 'erledigt'}, HTTP_HX_REQUEST='true') + response = admitted_client.post(url, {'status': 'erledigt'}, HTTP_HX_REQUEST='true') assert response.status_code == 200 aufgabe.refresh_from_db() assert aufgabe.status == 'erledigt' @pytest.mark.django_db -def test_ueberfaellige_aufgabe_auto_update(client): +def test_ueberfaellige_aufgabe_auto_update(admitted_client): from datetime import date, timedelta a = AusschreibungFactory() aufgabe = AufgabeFactory(ausschreibung=a, frist=date.today() - timedelta(days=1), status='offen') url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) - client.get(url) + admitted_client.get(url) aufgabe.refresh_from_db() assert aufgabe.status == 'ueberfaellig' @@ -75,21 +75,21 @@ def test_ueberfaellige_aufgabe_auto_update(client): @pytest.mark.django_db -def test_bieterfrage_neu_prefill_anforderung(client): +def test_bieterfrage_neu_prefill_anforderung(admitted_client): a = AusschreibungFactory() anf = AnforderungFactory(ausschreibung=a) url = reverse('ausschreibungen:bieterfragen:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.get(url, {'anforderung_id': anf.pk}) + response = admitted_client.get(url, {'anforderung_id': anf.pk}) assert response.status_code == 200 assert str(anf.pk).encode() in response.content @pytest.mark.django_db -def test_bieterfrage_antwort_speichern(client): +def test_bieterfrage_antwort_speichern(admitted_client): bf = BieterfragenFactory(status='eingereicht') url = reverse('ausschreibungen:bieterfragen:antwort', kwargs={'ausschreibung_id': bf.ausschreibung_id, 'pk': bf.pk}) - response = client.post(url, {'antwort': 'Die Antwort lautet 42.', 'auswirkung_angebot': ''}) + response = admitted_client.post(url, {'antwort': 'Die Antwort lautet 42.', 'auswirkung_angebot': ''}) assert response.status_code == 302 bf.refresh_from_db() assert bf.antwort == 'Die Antwort lautet 42.' @@ -116,7 +116,7 @@ def test_frist_effektiv_ohne_frist(): @pytest.mark.django_db -def test_ueberfaellig_ohne_frist_nach_7_tagen(client): +def test_ueberfaellig_ohne_frist_nach_7_tagen(admitted_client): from datetime import timedelta from django.utils import timezone a = AusschreibungFactory() @@ -124,7 +124,7 @@ def test_ueberfaellig_ohne_frist_nach_7_tagen(client): aufgabe = AufgabeFactory(ausschreibung=a, frist=None, status='offen') Aufgabe.objects.filter(pk=aufgabe.pk).update(erstellt_am=alte_erstellung) url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) - client.get(url) + admitted_client.get(url) aufgabe.refresh_from_db() assert aufgabe.status == 'ueberfaellig' @@ -133,14 +133,14 @@ def test_ueberfaellig_ohne_frist_nach_7_tagen(client): @pytest.mark.django_db -def test_aufgaben_verknuepfung_erstellen(client): +def test_aufgaben_verknuepfung_erstellen(admitted_client): from django.contrib.contenttypes.models import ContentType aufgabe = AufgabeFactory() anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung) ct = ContentType.objects.get_for_model(anf) url = reverse('ausschreibungen:aufgaben:verknuepfung_neu', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'ziel_typ': ct.pk, 'ziel_id': anf.pk, 'kommentar': 'Testverknüpfung', @@ -150,7 +150,7 @@ def test_aufgaben_verknuepfung_erstellen(client): @pytest.mark.django_db -def test_aufgaben_verknuepfung_loeschen(client): +def test_aufgaben_verknuepfung_loeschen(admitted_client): from django.contrib.contenttypes.models import ContentType aufgabe = AufgabeFactory() anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung) @@ -160,7 +160,7 @@ def test_aufgaben_verknuepfung_loeschen(client): ) url = reverse('ausschreibungen:aufgaben:verknuepfung_loeschen', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk, 'vk_pk': vk.pk}) - response = client.post(url, {}, HTTP_HX_REQUEST='true') + response = admitted_client.post(url, {}, HTTP_HX_REQUEST='true') assert response.status_code == 200 assert not AufgabenVerknuepfung.objects.filter(pk=vk.pk).exists() @@ -176,24 +176,24 @@ def tmp_issue_db(tmp_path, settings): @pytest.mark.django_db -def test_external_issue_erstellen(client, tmp_issue_db): +def test_external_issue_erstellen(admitted_client, tmp_issue_db): aufgabe = AufgabeFactory() url = reverse('ausschreibungen:aufgaben:external_issue', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = client.post(url, {'notizen': ''}, HTTP_HX_REQUEST='true') + response = admitted_client.post(url, {'notizen': ''}, HTTP_HX_REQUEST='true') assert response.status_code == 200 assert ExternalIssue.objects.filter(aufgabe=aufgabe, issue_facade_backend='local').exists() @pytest.mark.django_db -def test_external_issue_loeschen(client): +def test_external_issue_loeschen(admitted_client): aufgabe = AufgabeFactory() ei = ExternalIssue.objects.create( aufgabe=aufgabe, issue_facade_backend='local', issue_key='#1' ) url = reverse('ausschreibungen:aufgaben:external_issue_loeschen', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = client.post(url, {}, HTTP_HX_REQUEST='true') + response = admitted_client.post(url, {}, HTTP_HX_REQUEST='true') assert response.status_code == 200 assert not ExternalIssue.objects.filter(pk=ei.pk).exists() @@ -250,11 +250,11 @@ def test_status_synchronisieren(tmp_issue_db): @pytest.mark.django_db -def test_external_issue_bearbeiten_view_erstellt_issue(client, tmp_issue_db): +def test_external_issue_bearbeiten_view_erstellt_issue(admitted_client, tmp_issue_db): aufgabe = AufgabeFactory() url = reverse('ausschreibungen:aufgaben:external_issue', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = client.post(url, {'notizen': 'Test-Notiz'}, HTTP_HX_REQUEST='true') + response = admitted_client.post(url, {'notizen': 'Test-Notiz'}, HTTP_HX_REQUEST='true') assert response.status_code == 200 ei = ExternalIssue.objects.get(aufgabe=aufgabe) assert ei.issue_facade_backend == 'local' @@ -263,7 +263,7 @@ def test_external_issue_bearbeiten_view_erstellt_issue(client, tmp_issue_db): @pytest.mark.django_db -def test_external_issue_sync_view(client, tmp_issue_db): +def test_external_issue_sync_view(admitted_client, tmp_issue_db): from .issue_facade import lokales_issue_erstellen aufgabe = AufgabeFactory() daten = lokales_issue_erstellen(aufgabe) @@ -276,7 +276,7 @@ def test_external_issue_sync_view(client, tmp_issue_db): ) url = reverse('ausschreibungen:aufgaben:external_issue_sync', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = client.post(url, {}, HTTP_HX_REQUEST='true') + response = admitted_client.post(url, {}, HTTP_HX_REQUEST='true') assert response.status_code == 200 ei.refresh_from_db() assert ei.sync_status == 'open' diff --git a/vergabe_teilnahme/apps/ausschreibungen/tests.py b/vergabe_teilnahme/apps/ausschreibungen/tests.py index b4b2731..c3a7ad2 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/tests.py +++ b/vergabe_teilnahme/apps/ausschreibungen/tests.py @@ -54,47 +54,47 @@ def test_naechste_frist_none_when_past(): # --- View tests --- @pytest.mark.django_db -def test_liste_get(client): - response = client.get(reverse("ausschreibungen:liste")) +def test_liste_get(admitted_client): + response = admitted_client.get(reverse("ausschreibungen:liste")) assert response.status_code == 200 @pytest.mark.django_db -def test_neu_get(client): - response = client.get(reverse("ausschreibungen:neu")) +def test_neu_get(admitted_client): + response = admitted_client.get(reverse("ausschreibungen:neu")) assert response.status_code == 200 @pytest.mark.django_db -def test_neu_post_valid(client): +def test_neu_post_valid(admitted_client): data = {"titel": "Neue Ausschreibung", "ausschreiber": "Stadt XY"} - response = client.post(reverse("ausschreibungen:neu"), data) + response = admitted_client.post(reverse("ausschreibungen:neu"), data) assert response.status_code == 302 a = Ausschreibung.objects.get(titel="Neue Ausschreibung") assert response.url == reverse("ausschreibungen:detail", kwargs={"pk": a.pk}) @pytest.mark.django_db -def test_detail_get(client): +def test_detail_get(admitted_client): a = AusschreibungFactory() - response = client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk})) + response = admitted_client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk})) assert response.status_code == 200 @pytest.mark.django_db -def test_status_post(client): +def test_status_post(admitted_client): a = AusschreibungFactory(status=1) url = reverse("ausschreibungen:status", kwargs={"pk": a.pk}) - response = client.post(url, {"status": "4"}) + response = admitted_client.post(url, {"status": "4"}) assert response.status_code == 200 a.refresh_from_db() assert a.status == 4 @pytest.mark.django_db -def test_status_htmx_returns_partial(client): +def test_status_htmx_returns_partial(admitted_client): a = AusschreibungFactory(status=1) url = reverse("ausschreibungen:status", kwargs={"pk": a.pk}) - response = client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true") + response = admitted_client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true") assert response.status_code == 200 assert b"status-widget" in response.content diff --git a/vergabe_teilnahme/apps/bibliothek/tests.py b/vergabe_teilnahme/apps/bibliothek/tests.py index 740464f..44465ee 100644 --- a/vergabe_teilnahme/apps/bibliothek/tests.py +++ b/vergabe_teilnahme/apps/bibliothek/tests.py @@ -19,12 +19,12 @@ def test_nachweis_ist_abgelaufen_false_without_date(): @pytest.mark.django_db -def test_nachweis_liste_filter_abgelaufen(client): +def test_nachweis_liste_filter_abgelaufen(admitted_client): heute = date.today() Nachweis.objects.create(titel='AbgelaufenerNachweis', gueltig_bis=heute - timedelta(days=5)) Nachweis.objects.create(titel='NochAktuellerNachweis', gueltig_bis=heute + timedelta(days=100)) url = reverse('bibliothek:nachweise_liste') - response = client.get(url + '?tab=abgelaufen') + response = admitted_client.get(url + '?tab=abgelaufen') assert response.status_code == 200 content = response.content.decode() assert 'AbgelaufenerNachweis' in content @@ -32,7 +32,7 @@ def test_nachweis_liste_filter_abgelaufen(client): @pytest.mark.django_db -def test_entscheidungsregel_inaktiv_nicht_in_liste(client): +def test_entscheidungsregel_inaktiv_nicht_in_liste(admitted_client): Entscheidungsregel.objects.create( regelname='Aktive Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True ) @@ -40,7 +40,7 @@ def test_entscheidungsregel_inaktiv_nicht_in_liste(client): regelname='Inaktive Regel', kategorie='frist', empfehlung='pruefen', aktiv=False ) url = reverse('bibliothek:entscheidungsregeln_liste') - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 content = response.content.decode() assert 'Aktive Regel' in content @@ -48,21 +48,21 @@ def test_entscheidungsregel_inaktiv_nicht_in_liste(client): @pytest.mark.django_db -def test_entscheidungsregel_toggle(client): +def test_entscheidungsregel_toggle(admitted_client): r = Entscheidungsregel.objects.create( regelname='Toggle-Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True ) url = reverse('bibliothek:er_toggle', kwargs={'pk': r.pk}) - client.post(url) + admitted_client.post(url) r.refresh_from_db() assert r.aktiv is False @pytest.mark.django_db -def test_nachweis_neue_version_ersetzt_alten(client): +def test_nachweis_neue_version_ersetzt_alten(admitted_client): alt = Nachweis.objects.create(titel='Zertifikat ISO', version='1.0') url = reverse('bibliothek:nachweis_version', kwargs={'pk': alt.pk}) - client.post(url, { + admitted_client.post(url, { 'titel': 'Zertifikat ISO', 'version': '1.0', 'sprache': 'de', diff --git a/vergabe_teilnahme/apps/dokumente/downloads.py b/vergabe_teilnahme/apps/dokumente/downloads.py new file mode 100644 index 0000000..e95b091 --- /dev/null +++ b/vergabe_teilnahme/apps/dokumente/downloads.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from django.conf import settings +from django.http import FileResponse, Http404 +from django.views.decorators.http import require_safe + + +@require_safe +def protected_media(request, path): + """Download company uploads through the application authentication gate. + + The invited pilot has one company per deployment. MEDIA_ROOT must contain + uploads only; operational databases and credentials live outside this root. + """ + root = Path(settings.MEDIA_ROOT).resolve() + try: + target = (root / path).resolve() + if not target.is_relative_to(root) or not target.is_file(): + raise Http404 + file = target.open('rb') + except (OSError, RuntimeError, ValueError) as exc: + raise Http404 from exc + return FileResponse(file, as_attachment=True, filename=target.name) diff --git a/vergabe_teilnahme/apps/dokumente/tests.py b/vergabe_teilnahme/apps/dokumente/tests.py index 2e6d010..97e4c29 100644 --- a/vergabe_teilnahme/apps/dokumente/tests.py +++ b/vergabe_teilnahme/apps/dokumente/tests.py @@ -26,46 +26,46 @@ def _pdf_file(name='test.pdf'): @pytest.mark.django_db -def test_dokument_upload_valid(client, tmp_path, settings): +def test_dokument_upload_valid(admitted_client, tmp_path, settings): settings.MEDIA_ROOT = tmp_path a = AusschreibungFactory() url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, {'datei': _pdf_file(), 'kategorie': 'intern', 'version': '1.0'}) + response = admitted_client.post(url, {'datei': _pdf_file(), 'kategorie': 'intern', 'version': '1.0'}) assert response.status_code == 302 assert Dokument.objects.filter(ausschreibung=a).exists() @pytest.mark.django_db -def test_dokument_upload_invalid_extension(client, tmp_path, settings): +def test_dokument_upload_invalid_extension(admitted_client, tmp_path, settings): settings.MEDIA_ROOT = tmp_path a = AusschreibungFactory() url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) bad_file = SimpleUploadedFile('malware.exe', b'MZ bad', content_type='application/octet-stream') - response = client.post(url, {'datei': bad_file, 'kategorie': 'intern', 'version': '1.0'}) + response = admitted_client.post(url, {'datei': bad_file, 'kategorie': 'intern', 'version': '1.0'}) assert response.status_code == 200 assert not Dokument.objects.filter(ausschreibung=a).exists() @pytest.mark.django_db -def test_dokument_upload_too_large(client, tmp_path, settings): +def test_dokument_upload_too_large(admitted_client, tmp_path, settings): settings.MEDIA_ROOT = tmp_path settings.MAX_UPLOAD_SIZE = 10 a = AusschreibungFactory() url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) big_file = SimpleUploadedFile('big.pdf', b'%PDF' + b'x' * 100, content_type='application/pdf') - response = client.post(url, {'datei': big_file, 'kategorie': 'intern', 'version': '1.0'}) + response = admitted_client.post(url, {'datei': big_file, 'kategorie': 'intern', 'version': '1.0'}) assert response.status_code == 200 assert not Dokument.objects.filter(ausschreibung=a).exists() @pytest.mark.django_db -def test_dokument_neue_version(client, tmp_path, settings): +def test_dokument_neue_version(admitted_client, tmp_path, settings): settings.MEDIA_ROOT = tmp_path altes_dok = DokumentFactory(version='1.0') a = altes_dok.ausschreibung url = reverse('ausschreibungen:dokumente:neue_version', kwargs={'ausschreibung_id': a.pk, 'pk': altes_dok.pk}) - response = client.post(url, {'datei': _pdf_file('v2.pdf'), 'version': '2.0'}) + response = admitted_client.post(url, {'datei': _pdf_file('v2.pdf'), 'version': '2.0'}) assert response.status_code == 302 altes_dok.refresh_from_db() assert altes_dok.status == 'ersetzt' @@ -73,12 +73,12 @@ def test_dokument_neue_version(client, tmp_path, settings): @pytest.mark.django_db -def test_dokument_finale_version(client): +def test_dokument_finale_version(admitted_client): dok = DokumentFactory(status='freigegeben') a = dok.ausschreibung url = reverse('ausschreibungen:dokumente:finale_version', kwargs={'ausschreibung_id': a.pk, 'pk': dok.pk}) - response = client.post(url) + response = admitted_client.post(url) assert response.status_code == 200 dok.refresh_from_db() assert dok.finale_abgabeversion is True diff --git a/vergabe_teilnahme/apps/lose/tests.py b/vergabe_teilnahme/apps/lose/tests.py index f10c3a1..f7a04bb 100644 --- a/vergabe_teilnahme/apps/lose/tests.py +++ b/vergabe_teilnahme/apps/lose/tests.py @@ -39,28 +39,28 @@ class NachweisFactory(factory.django.DjangoModelFactory): @pytest.mark.django_db -def test_lose_liste_get(client): +def test_lose_liste_get(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': a.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 @pytest.mark.django_db -def test_los_neu_post(client): +def test_los_neu_post(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:lose:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, {'losnummer': 'L01', 'lostitel': 'Testlos'}) + response = admitted_client.post(url, {'losnummer': 'L01', 'lostitel': 'Testlos'}) assert response.status_code == 302 assert Los.objects.filter(ausschreibung=a, losnummer='L01').exists() @pytest.mark.django_db -def test_los_detail_get(client): +def test_los_detail_get(admitted_client): los = LosFactory() url = reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': los.ausschreibung_id, 'los_pk': los.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 @@ -68,21 +68,21 @@ def test_los_detail_get(client): @pytest.mark.django_db -def test_anforderung_neu_post_muss(client): +def test_anforderung_neu_post_muss(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:lose:anforderung_neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, {'titel': 'Neue Anforderung', 'verbindlichkeit': 'muss', + response = admitted_client.post(url, {'titel': 'Neue Anforderung', 'verbindlichkeit': 'muss', 'erfuellungsstatus': 'offen'}) assert response.status_code == 302 assert Anforderung.objects.filter(ausschreibung=a, titel='Neue Anforderung').exists() @pytest.mark.django_db -def test_anforderung_status_htmx(client): +def test_anforderung_status_htmx(admitted_client): anf = AnforderungFactory() url = reverse('ausschreibungen:lose:anforderung_status', kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk}) - response = client.post(url, {'erfuellungsstatus': 'nicht_erfuellbar'}, + response = admitted_client.post(url, {'erfuellungsstatus': 'nicht_erfuellbar'}, HTTP_HX_REQUEST='true') assert response.status_code == 200 anf.refresh_from_db() @@ -90,7 +90,7 @@ def test_anforderung_status_htmx(client): @pytest.mark.django_db -def test_ausschlusskriterium_banner_shown(client): +def test_ausschlusskriterium_banner_shown(admitted_client): a = AusschreibungFactory() AnforderungFactory( ausschreibung=a, @@ -98,17 +98,17 @@ def test_ausschlusskriterium_banner_shown(client): erfuellungsstatus='nicht_erfuellbar', ) url = reverse('ausschreibungen:entscheidung', kwargs={'pk': a.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 assert b'Nicht erf\xc3\xbcllbare Ausschlusskriterien' in response.content @pytest.mark.django_db -def test_nachweis_zuordnen(client): +def test_nachweis_zuordnen(admitted_client): anf = AnforderungFactory() n = NachweisFactory() url = reverse('ausschreibungen:lose:nachweis_zuordnen', kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk}) - response = client.post(url, {'nachweis_pk': n.pk}) + response = admitted_client.post(url, {'nachweis_pk': n.pk}) assert response.status_code == 200 assert anf.nachweise.filter(pk=n.pk).exists() diff --git a/vergabe_teilnahme/apps/marktbegleiter/tests.py b/vergabe_teilnahme/apps/marktbegleiter/tests.py index 065935e..5237589 100644 --- a/vergabe_teilnahme/apps/marktbegleiter/tests.py +++ b/vergabe_teilnahme/apps/marktbegleiter/tests.py @@ -12,11 +12,11 @@ def make_mb(name='TestBegleiter', **kwargs): @pytest.mark.django_db -def test_passage_anlegen_mit_score_10(client): +def test_passage_anlegen_mit_score_10(admitted_client): a = AusschreibungFactory() mb = make_mb() url = reverse('marktbegleiter:passagen:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'passage': 'Musterpassage aus dem Dokument', 'marktbegleiter': mb.pk, 'verlaesslichkeitsscore': 10, @@ -40,7 +40,7 @@ def test_passage_score_zu_hoch_validierungsfehler(): @pytest.mark.django_db -def test_auswertung_score_durchschnitt(client): +def test_auswertung_score_durchschnitt(admitted_client): a = AusschreibungFactory() mb = make_mb() Ausschreibungspassage.objects.create( @@ -50,14 +50,14 @@ def test_auswertung_score_durchschnitt(client): ausschreibung=a, marktbegleiter=mb, passage='P2', verlaesslichkeitsscore=6 ) url = reverse('marktbegleiter:auswertung', kwargs={'pk': mb.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 content = response.content.decode() assert '7' in content @pytest.mark.django_db -def test_marktbegleiter_detail_zeigt_passagen(client): +def test_marktbegleiter_detail_zeigt_passagen(admitted_client): a = AusschreibungFactory() mb = make_mb(name='DetailBegleiter') Ausschreibungspassage.objects.create( @@ -65,6 +65,6 @@ def test_marktbegleiter_detail_zeigt_passagen(client): passage='Sichtbare Passage', verlaesslichkeitsscore=5 ) url = reverse('marktbegleiter:detail', kwargs={'pk': mb.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 assert b'Sichtbare Passage' in response.content diff --git a/vergabe_teilnahme/apps/nachbetrachtung/tests.py b/vergabe_teilnahme/apps/nachbetrachtung/tests.py index 54f9ba5..6c41c89 100644 --- a/vergabe_teilnahme/apps/nachbetrachtung/tests.py +++ b/vergabe_teilnahme/apps/nachbetrachtung/tests.py @@ -38,11 +38,11 @@ def test_abgabe_vollstaendigkeit_mit_freigabe(): @pytest.mark.django_db -def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(client): +def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(admitted_client): from vergabe_teilnahme.apps.aufgaben.models import Aufgabe a = AusschreibungFactory(status=9) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'ergebnis': 'gewonnen', 'verlustgruende': '[]', }) @@ -53,21 +53,21 @@ def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(client): @pytest.mark.django_db -def test_ergebnis_verloren_setzt_status_11(client): +def test_ergebnis_verloren_setzt_status_11(admitted_client): a = AusschreibungFactory(status=9) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) - client.post(url, {'ergebnis': 'verloren', 'verlustgruende': '[]'}) + admitted_client.post(url, {'ergebnis': 'verloren', 'verlustgruende': '[]'}) a.refresh_from_db() assert a.status == 11 @pytest.mark.django_db -def test_verlustgruende_json_gespeichert(client): +def test_verlustgruende_json_gespeichert(admitted_client): import json a = AusschreibungFactory(status=9) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) gruende = [{'grund': 'Zu teuer', 'kategorie': 'preis', 'verlaesslichkeit': 4}] - client.post(url, {'ergebnis': 'verloren', 'verlustgruende': json.dumps(gruende)}) + admitted_client.post(url, {'ergebnis': 'verloren', 'verlustgruende': json.dumps(gruende)}) nb = Nachbetrachtung.objects.get(ausschreibung=a) assert nb.verlustgruende[0]['grund'] == 'Zu teuer' assert nb.verlustgruende[0]['kategorie'] == 'preis' diff --git a/vergabe_teilnahme/apps/partner/tests.py b/vergabe_teilnahme/apps/partner/tests.py index f0284e6..1690c81 100644 --- a/vergabe_teilnahme/apps/partner/tests.py +++ b/vergabe_teilnahme/apps/partner/tests.py @@ -12,43 +12,43 @@ def make_sub(praeferenz='zugelassen', name='TestSub', **kwargs): @pytest.mark.django_db -def test_subunternehmer_zuordnung_zu_los(client): +def test_subunternehmer_zuordnung_zu_los(admitted_client): a = AusschreibungFactory() los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') sub = make_sub() url = reverse('partner:su_zuordnen', kwargs={'ausschreibung_id': a.pk, 'los_pk': los.pk}) - client.post(url, {'subunternehmer_id': sub.pk, 'konkrete_leistung': 'IT-Support'}) + admitted_client.post(url, {'subunternehmer_id': sub.pk, 'konkrete_leistung': 'IT-Support'}) assert SubunternehmerZuordnung.objects.filter(subunternehmer=sub, ausschreibung=a, los=los).exists() @pytest.mark.django_db -def test_gesperrter_subunternehmer_im_suchmodal(client): +def test_gesperrter_subunternehmer_im_suchmodal(admitted_client): a = AusschreibungFactory() los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') make_sub(praeferenz='gesperrt', name='GesperrterSub') url = reverse('partner:su_suche_modal', kwargs={'ausschreibung_id': a.pk, 'los_pk': los.pk}) - response = client.get(url + '?q=GesperrterSub') + response = admitted_client.get(url + '?q=GesperrterSub') assert response.status_code == 200 assert b'gesperrt' in response.content.lower() @pytest.mark.django_db -def test_subunternehmer_praeferenz_update(client): +def test_subunternehmer_praeferenz_update(admitted_client): sub = make_sub(praeferenz='zugelassen') url = reverse('partner:su_praeferenz', kwargs={'pk': sub.pk}) - client.post(url, {'praeferenz': 'bevorzugt'}) + admitted_client.post(url, {'praeferenz': 'bevorzugt'}) sub.refresh_from_db() assert sub.praeferenz == 'bevorzugt' @pytest.mark.django_db -def test_zuordnung_toggle_zusage(client): +def test_zuordnung_toggle_zusage(admitted_client): a = AusschreibungFactory() los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') sub = make_sub() z = SubunternehmerZuordnung.objects.create(subunternehmer=sub, ausschreibung=a, los=los) assert z.zusage_vorhanden is False url = reverse('partner:zuordnung_toggle', kwargs={'pk': z.pk}) - client.post(url, {'feld': 'zusage_vorhanden'}) + admitted_client.post(url, {'feld': 'zusage_vorhanden'}) z.refresh_from_db() assert z.zusage_vorhanden is True diff --git a/vergabe_teilnahme/apps/preise/tests.py b/vergabe_teilnahme/apps/preise/tests.py index 4b791d3..515468b 100644 --- a/vergabe_teilnahme/apps/preise/tests.py +++ b/vergabe_teilnahme/apps/preise/tests.py @@ -23,10 +23,10 @@ class PreispunktFactory(factory.django.DjangoModelFactory): @pytest.mark.django_db -def test_vergleichsgewicht_null_gespeichert(client): +def test_vergleichsgewicht_null_gespeichert(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'leistungstyp': 'Test', 'konkrete_leistung': 'Leistung mit Gewicht 0', 'mengeneinheit': 'Stück', @@ -39,10 +39,10 @@ def test_vergleichsgewicht_null_gespeichert(client): @pytest.mark.django_db -def test_vergleichsgewicht_zu_gross_validation_error(client): +def test_vergleichsgewicht_zu_gross_validation_error(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'leistungstyp': 'Test', 'konkrete_leistung': 'Leistung', 'mengeneinheit': 'Stück', @@ -71,10 +71,10 @@ def test_gewichteter_durchschnitt_berechnung(): @pytest.mark.django_db -def test_auswertung_view_200(client): +def test_auswertung_view_200(admitted_client): a = AusschreibungFactory() PreispunktFactory(ausschreibung=a, einzelpreis=Decimal('150.00')) url = reverse('ausschreibungen:preise:auswertung', kwargs={'ausschreibung_id': a.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 assert 'ergebnis' in response.context diff --git a/vergabe_teilnahme/settings/base.py b/vergabe_teilnahme/settings/base.py index f9fc320..29105aa 100644 --- a/vergabe_teilnahme/settings/base.py +++ b/vergabe_teilnahme/settings/base.py @@ -35,6 +35,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'vergabe_teilnahme.apps.accounts.middleware.PilotLoginRequiredMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -67,6 +68,9 @@ DATABASES = { } AUTH_USER_MODEL = 'accounts.Mitarbeiter' +LOGIN_URL = 'accounts:login' +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = 'accounts:login' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/vergabe_teilnahme/templates/accounts/base.html b/vergabe_teilnahme/templates/accounts/base.html new file mode 100644 index 0000000..efcde65 --- /dev/null +++ b/vergabe_teilnahme/templates/accounts/base.html @@ -0,0 +1,16 @@ +{% load static %} + + + + + + {% block title %}Anmelden{% endblock %} · Vergabe Teilnahme + + + +
+

Vergabe Teilnahme

+ {% block content %}{% endblock %} +
+ + diff --git a/vergabe_teilnahme/templates/accounts/login.html b/vergabe_teilnahme/templates/accounts/login.html new file mode 100644 index 0000000..8d36c17 --- /dev/null +++ b/vergabe_teilnahme/templates/accounts/login.html @@ -0,0 +1,13 @@ +{% extends "accounts/base.html" %} +{% block content %} +

Anmelden

+

Melden Sie sich mit Ihrem eingerichteten Benutzerkonto an.

+
+ {% csrf_token %} + {{ form.as_p }} + + +
+

Für Zugang oder ein neues Passwort wenden Sie sich bitte an Ihre Ansprechperson für den Pilotbetrieb.

+{% endblock %} diff --git a/vergabe_teilnahme/templates/accounts/password_change.html b/vergabe_teilnahme/templates/accounts/password_change.html new file mode 100644 index 0000000..834e3a9 --- /dev/null +++ b/vergabe_teilnahme/templates/accounts/password_change.html @@ -0,0 +1,11 @@ +{% extends "accounts/base.html" %} +{% block title %}Passwort ändern{% endblock %} +{% block content %} +

Passwort ändern

+
+ {% csrf_token %} + {{ form.as_p }} + +
+Zurück zur Übersicht +{% endblock %} diff --git a/vergabe_teilnahme/templates/accounts/password_change_done.html b/vergabe_teilnahme/templates/accounts/password_change_done.html new file mode 100644 index 0000000..6062fb7 --- /dev/null +++ b/vergabe_teilnahme/templates/accounts/password_change_done.html @@ -0,0 +1,7 @@ +{% extends "accounts/base.html" %} +{% block title %}Passwort gespeichert{% endblock %} +{% block content %} +

Passwort gespeichert

+

Ihr neues Passwort ist jetzt aktiv.

+Zur Übersicht +{% endblock %} diff --git a/vergabe_teilnahme/templates/partials/topbar.html b/vergabe_teilnahme/templates/partials/topbar.html index cb34d4f..6e3be79 100644 --- a/vergabe_teilnahme/templates/partials/topbar.html +++ b/vergabe_teilnahme/templates/partials/topbar.html @@ -39,8 +39,12 @@
{{ request.user.get_rolle_display|default:"Mitarbeiter" }}
- Abmelden + Passwort ändern +
+ {% csrf_token %} + +
diff --git a/vergabe_teilnahme/urls.py b/vergabe_teilnahme/urls.py index 347b968..3fc68eb 100644 --- a/vergabe_teilnahme/urls.py +++ b/vergabe_teilnahme/urls.py @@ -1,14 +1,15 @@ from django.contrib import admin -from django.conf import settings -from django.conf.urls.static import static +from django.contrib.auth.decorators import login_not_required from django.http import JsonResponse from django.shortcuts import redirect from django.urls import include, path from vergabe_teilnahme.apps.core import views as core_views +from vergabe_teilnahme.apps.dokumente.downloads import protected_media from vergabe_teilnahme.apps.preise import views as preise_views +@login_not_required def health(request): return JsonResponse({'status': 'ok'}) @@ -21,6 +22,8 @@ handler404 = 'vergabe_teilnahme.apps.core.views.custom_404' handler500 = 'vergabe_teilnahme.apps.core.views.custom_500' urlpatterns = [ + path('accounts/', include('vergabe_teilnahme.apps.accounts.urls')), + path('media/', protected_media, name='protected_media'), path('admin/', admin.site.urls), path('health/', health), path('', home, name='home'), @@ -49,6 +52,3 @@ urlpatterns = [ path('core/attrs////loeschen/', core_views.custom_attribute_loeschen, name='custom_attribute_loeschen'), path('core/attrs////sort/', core_views.custom_attribute_sort, name='custom_attribute_sort'), ] - -if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/workplans/VERGABE-WP-0018-customer-factory-delivery.md b/workplans/VERGABE-WP-0018-customer-factory-delivery.md index b5a74be..58dfbeb 100644 --- a/workplans/VERGABE-WP-0018-customer-factory-delivery.md +++ b/workplans/VERGABE-WP-0018-customer-factory-delivery.md @@ -8,7 +8,7 @@ status: blocked owner: the-custodian topic_slug: vergabe-teilnahme created: "2026-09-08" -updated: "2026-09-08" +updated: "2026-09-11" related: [HFACT-WP-0001, REUSE-WP-0022] state_hub_workstream_id: "27a95f7b-cec4-50ca-8383-c1c95d996217" --- @@ -69,8 +69,7 @@ state_hub_task_id: "6e428152-aad6-5184-8b1d-8860ec0b9ae6" Coordinator: the-custodian; product acceptance: Bernd Worsch; deployment owner: railiance-apps and admitted Railiance placement owner. Prepare exact chart/image, host, tenant, credential binding, existing-data disposition, backup/restore, -rollback and UI acceptance evidence before release. Use an isolated internal -pilot first; existing `vergabe_db` is not test data and must not be relocated or +rollback and UI acceptance evidence before release. Use the invited single-company pilot in VERGABE-WP-0019; existing `vergabe_db` is not test data and must not be relocated or overwritten implicitly. No customer-ready claim until admitted release passes. Return the release and consumer evidence to HFACT-WP-0001-T06/T07. @@ -107,3 +106,14 @@ T01/T02 are done; the workplan is blocked on T03's admitted customer release. HFACT-WP-0001 consumes the source and artifact receipts without claiming a current governed worker run. Forgejo PR 1 metadata needs its authenticated `manually-merged` receipt; source integration itself is verified on main. + + +## Invited-pilot sequencing decision — 2026-09-11 + +The user selected an invited pilot with manual onboarding and deferred pricing. +VERGABE-WP-0019 owns concrete product readiness; RAPPS-WP-0014 owns deployment +and recovery. Their preparation can advance independently of T03's governed +worker dependency. This record continues to own factory-produced delivery +acceptance, so a manually prepared customer pilot cannot falsely close HFACT +worker proof. The old main-fa9f082 image predates the required login gate and +must not be used as the invited-pilot release merely because its CI passed. diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md new file mode 100644 index 0000000..8ecc916 --- /dev/null +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -0,0 +1,109 @@ +--- +id: VERGABE-WP-0019 +type: workplan +title: "Admit the first invited company pilot with protected access and recoverable data" +domain: communication +repo: vergabe-teilnahme +status: active +owner: the-custodian +topic_slug: vergabe-teilnahme +created: "2026-09-11" +updated: "2026-09-11" +related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001] +--- + +# Invited company pilot + +## Confirm the bounded customer milestone + +```task +id: VERGABE-WP-0019-T01 +status: done +priority: high +assignee: the-custodian +``` + +User decision on 2026-09-11: start with an invited pilot, one company and several +users, manually onboarded. Pricing is a later improvement and is not an entry +gate. Use the existing single-company product: each admitted company has an +isolated deployment, database and data volumes. All active company members can +collaborate on the company's tenders; the existing v1 domain approval roles +remain descriptive. Django staff/superuser administration remains restricted. +External partners remain data objects. No public registration or shared-app +multitenancy is introduced. + +Product readiness proceeds alongside the governed factory runtime. It does not +wait for the fourteen-day factory value study. VERGABE-WP-0018-T03 retains the +separate claim that the factory can produce and deliver the customer release; +HFACT-WP-0001-T05 still needs a natural governed worker trace. + +## Require invited access across the UI and uploaded documents + +```task +id: VERGABE-WP-0019-T02 +status: done +priority: high +assignee: the-custodian +``` + +Use Django's default-deny login middleware with explicit health/login exemptions, +German login and password-change forms, CSRF-protected POST logout, and full-page +reauthentication for expired HTMX sessions. Protect uploaded files in production +and development, serve private attachments only within MEDIA_ROOT, and keep +operational issue state outside that downloadable root. Do not cache company +responses. Inactive users lose existing session access; ordinary members do not +gain Django administration privileges. + +The 12 access regressions initially produced 9 failures, including actual +anonymous tender read/create. After the fix all 94 application tests pass +locally and in the existing container target; Vite assets build, 139 static +files collect, and migration drift is absent. New/changed access modules pass +Ruff. Source acceptance is not native customer admission. + +## Publish and prove the isolated deployment and recovery contract + +```task +id: VERGABE-WP-0019-T03 +status: progress +priority: high +assignee: the-custodian +depends_on: [VERGABE-WP-0019-T02, RAPPS-WP-0014-T02, RAPPS-WP-0014-T03] +``` + +RAPPS-WP-0014 owns exact image/chart/host/namespace/database/Secret binding, +persistent media plus distinct issue-facade state, isolated restore rehearsal, +and rollback. The customer app owns live CI and immutable release evidence. +Obtain the exact company, user count, hostname and empty-vs-existing data choice +from the user; no password or user list is required in public work records. +The historical vergabe_db is not disposable and must not be overwritten. + +On 2026-09-11 the checked Railiance cluster contains neither the historical +vergabe-teilnahme namespace nor any Deployment whose name/image contains +vergabe/teilnahme. Treat the old runbook as historical intent, not a live return. +Native placement and existing data inventory remain required before cutover. + +## Accept onboarding, collaboration, recovery and support with pilot users + +```task +id: VERGABE-WP-0019-T04 +status: wait +priority: high +assignee: the-custodian +depends_on: [VERGABE-WP-0019-T03] +blocking_reason: "Await admitted company deployment, immutable release and demonstrated restore; company/host/data selection is pending." +``` + +Product acceptance: Bernd Worsch and the named company contact. Verify two +separate ordinary-user accounts can sign in, change passwords, create a tender +and lot, collaborate on tasks, upload/download a document, record a v1 domain +approval and submission, and report feedback. Verify anonymous access and a +revoked user fail, health remains available, and pod replacement preserves all +three data stores. Restore a backup into an isolated destination and repeat the +workflow without touching the live database. Record operator/support contact, +incident route, backup cadence/retention and demonstrated recovery time. Use +manual account creation/reset/deactivation through the admitted operator path; +never seed development accounts or put credentials in logs/workplans/chat. + +A successful invited pilot does not claim shared multitenancy, paid subscriptions, +HA or autonomous production release. Pricing is outside this milestone and will +need a later explicit product decision. From ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb Mon Sep 17 00:00:00 2001 From: repo-manager Date: Fri, 11 Sep 2026 16:35:29 +0200 Subject: [PATCH 06/24] repo.work.assign_missing_identifiers source: repo-manager reason: deterministic projection registration Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- workplans/VERGABE-WP-0019-invited-company-pilot.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index 8ecc916..3fbd9a6 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -10,6 +10,7 @@ topic_slug: vergabe-teilnahme created: "2026-09-11" updated: "2026-09-11" related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001] +state_hub_workstream_id: "85b5f304-d497-5570-bebf-3a3669ef6a7d" --- # Invited company pilot @@ -21,6 +22,7 @@ id: VERGABE-WP-0019-T01 status: done priority: high assignee: the-custodian +state_hub_task_id: "5785d35a-6501-57a3-aedf-3885db269e83" ``` User decision on 2026-09-11: start with an invited pilot, one company and several @@ -44,6 +46,7 @@ id: VERGABE-WP-0019-T02 status: done priority: high assignee: the-custodian +state_hub_task_id: "11e67327-aae4-5c83-99eb-86a8c82dd0be" ``` Use Django's default-deny login middleware with explicit health/login exemptions, @@ -68,6 +71,7 @@ status: progress priority: high assignee: the-custodian depends_on: [VERGABE-WP-0019-T02, RAPPS-WP-0014-T02, RAPPS-WP-0014-T03] +state_hub_task_id: "9065d5cb-2c65-561e-a237-e39da4f3b0fd" ``` RAPPS-WP-0014 owns exact image/chart/host/namespace/database/Secret binding, @@ -91,6 +95,7 @@ priority: high assignee: the-custodian depends_on: [VERGABE-WP-0019-T03] blocking_reason: "Await admitted company deployment, immutable release and demonstrated restore; company/host/data selection is pending." +state_hub_task_id: "db807f96-f5b8-528d-bc33-75a33fbbf1e4" ``` Product acceptance: Bernd Worsch and the named company contact. Verify two From 26e1008c0ff8c0ac21e6f3034cb1cf5bd0db8bd5 Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Fri, 11 Sep 2026 16:37:00 +0200 Subject: [PATCH 07/24] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-09-11: - update .custodian-brief.md for vergabe-teilnahme Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- .custodian-brief.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 2b55c19..d129e5a 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,16 +2,25 @@ # Custodian Brief — vergabe-teilnahme **Domain:** communication -**Last synced:** 2026-09-08 12:19 UTC +**Last synced:** 2026-09-11 14:36 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams +### Admit the first invited company pilot with protected access and recoverable data +Progress: 2/4 done | workplan_id: `85b5f304-d497-5570-bebf-3a3669ef6a7d` + +**Open tasks:** +- ! Accept onboarding, collaboration, recovery and support with pilot users `db807f96` + *(wait: Await admitted company deployment, immutable release and demonstrated restore; company/host/data selection is pending.)* +- ► Publish and prove the isolated deployment and recovery contract `9065d5cb` + ### Establish verified delivery for the primary customer tender product Progress: 2/3 done | workplan_id: `27a95f7b-cec4-50ca-8383-c1c95d996217` **Open tasks:** - ! Accept the first Railiance customer pilot release `6e428152` + *(wait: Image main-fa9f082 is tested and published. Await HFACT-WP-0001-T05 governed worker evidence plus exact Railiance tenant, deployment, database, storage, access and recovery acceptance.)* --- ## MCP Orientation (when available) From f7a388681c8e93106d04c7c77cce1fd82309b998 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 16:40:48 +0200 Subject: [PATCH 08/24] Record published invited-pilot release and verified runtime Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- WORK-RECORDS.md | 5 ++ docs/delivery.md | 12 ++++ .../2026-09-11-invited-pilot-release.json | 56 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 docs/evidence/2026-09-11-invited-pilot-release.json diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index af1695e..2d43dd2 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -9,6 +9,7 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | | workplan | VERGABE-WP-0018 | blocked | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | +| workplan | VERGABE-WP-0019 | active | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | workplan | VT-WP-0001 | finished | — | workplans/VT-WP-0001-projektgeruest.md | | workplan | VT-WP-0002 | finished | — | workplans/VT-WP-0002-fachmodelle.md | | workplan | VT-WP-0003 | finished | — | workplans/VT-WP-0003-basis-ui.md | @@ -29,6 +30,10 @@ | task | VERGABE-WP-0018-T01 | done | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | | task | VERGABE-WP-0018-T02 | done | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | | task | VERGABE-WP-0018-T03 | wait | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | +| task | VERGABE-WP-0019-T01 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T02 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T03 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T04 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VT-WP-0001-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T05 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T07 | done | — | workplans/VT-WP-0001-projektgeruest.md | diff --git a/docs/delivery.md b/docs/delivery.md index 6a47d16..c99ead0 100644 --- a/docs/delivery.md +++ b/docs/delivery.md @@ -83,3 +83,15 @@ explicit `admitted_client` fixture for company workflows. Existing end-to-end tests retain their explicit login. Container acceptance remains the release gate; native two-user workflow, restored data, support and rollout evidence remain open until performed on the admitted deployment. + + +## Published invited-pilot candidate — 2026-09-11 + +Source `ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb` (implementation `b7d7828`) +passed live application CI 29 and image publication 31. The registry resolves +`main-ccb6d1f` to +`sha256:963240ef4180a01e5b8af7fbeb88be27e56203a45da8ecd633326b4c6d262005`. +The published image contains the invited login/private-download gate. Exact +company/host/data binding, native deployment, coherent restore and two-user +acceptance remain open. See `docs/evidence/2026-09-11-invited-pilot-release.json` +for source, CI and artifact receipts; this candidate has not been deployed. diff --git a/docs/evidence/2026-09-11-invited-pilot-release.json b/docs/evidence/2026-09-11-invited-pilot-release.json new file mode 100644 index 0000000..6e1657c --- /dev/null +++ b/docs/evidence/2026-09-11-invited-pilot-release.json @@ -0,0 +1,56 @@ +{ + "schema": "vergabe.invited-pilot-release.v1", + "source_commit": "ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb", + "implementation_commit": "b7d7828", + "image": "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:963240ef4180a01e5b8af7fbeb88be27e56203a45da8ecd633326b4c6d262005", + "tag": "main-ccb6d1f", + "registry_resolution": "docker buildx imagetools inspect; Docker distribution manifest v2", + "live_ci": [ + { + "id": 7511, + "name": "build-and-push", + "head_branch": "main", + "head_sha": "ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb", + "run_number": 31, + "event": "push", + "display_title": "repo.work.assign_missing_identifiers", + "status": "success", + "workflow_id": "image.yaml", + "url": "https://forgejo.coulomb.social/coulomb/vergabe-teilnahme/actions/runs/31", + "created_at": "2026-09-11T14:36:48Z", + "updated_at": "2026-09-11T14:37:30Z", + "run_started_at": "2026-09-11T14:36:48Z" + }, + { + "id": 7508, + "name": "application-tests", + "head_branch": "main", + "head_sha": "ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb", + "run_number": 29, + "event": "push", + "display_title": "repo.work.assign_missing_identifiers", + "status": "success", + "workflow_id": "application-tests.yaml", + "url": "https://forgejo.coulomb.social/coulomb/vergabe-teilnahme/actions/runs/29", + "created_at": "2026-09-11T14:35:34Z", + "updated_at": "2026-09-11T14:36:43Z", + "run_started_at": "2026-09-11T14:35:34Z" + } + ], + "candidate_status": "tested_and_published_pending_company_placement_and_recovery", + "native_deployment": false, + "customer_data_used": false, + "published_runtime_smoke": { + "passed": true, + "uid": 999, + "health": 200, + "login": 200, + "anonymous_tender": 302, + "anonymous_media": 302, + "built_css": 200, + "pytest_in_runtime": false, + "settings": "vergabe_teilnahme.settings.prod", + "database": "disposable memory", + "native_admission": false + } +} From 8d7e7de536e722d7ead19b55210a4e9bb63f8d76 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 18:48:11 +0200 Subject: [PATCH 09/24] Record accepted prototype sizing and later metrics review Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- workplans/VERGABE-WP-0019-invited-company-pilot.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index 3fbd9a6..c178a34 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -9,7 +9,7 @@ owner: the-custodian topic_slug: vergabe-teilnahme created: "2026-09-11" updated: "2026-09-11" -related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001] +related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001, CUST-WP-0071] state_hub_workstream_id: "85b5f304-d497-5570-bebf-3a3669ef6a7d" --- @@ -81,6 +81,14 @@ Obtain the exact company, user count, hostname and empty-vs-existing data choice from the user; no password or user list is required in public work records. The historical vergabe_db is not disposable and must not be overwritten. +The user subsequently accepts a 60m application CPU request for one tenant with +very few users as a prototype of HelixForge app delivery and Railiance/NetKingdom +onboarding. RAPPS-WP-0014 owns the explicit pilot override; this is an accepted +low-allocation experiment, not measured production sizing. CUST-WP-0071 is +registered for later metrics-based sizing and a final weekly allocation-review +setup. That follow-up does not block this deployment. CPU limits, memory, +protected access and company/data isolation remain governed by the pilot packet. + On 2026-09-11 the checked Railiance cluster contains neither the historical vergabe-teilnahme namespace nor any Deployment whose name/image contains vergabe/teilnahme. Treat the old runbook as historical intent, not a live return. From 2b5213d5aca2088037f9dd986c8d18f67acc36cc Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 20:15:31 +0200 Subject: [PATCH 10/24] docs: select demo-company for invited pilot onboarding Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- workplans/VERGABE-WP-0019-invited-company-pilot.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index c178a34..80a370e 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -77,8 +77,13 @@ state_hub_task_id: "9065d5cb-2c65-561e-a237-e39da4f3b0fd" RAPPS-WP-0014 owns exact image/chart/host/namespace/database/Secret binding, persistent media plus distinct issue-facade state, isolated restore rehearsal, and rollback. The customer app owns live CI and immutable release evidence. -Obtain the exact company, user count, hostname and empty-vs-existing data choice -from the user; no password or user list is required in public work records. +The user now selects a fresh `demo-company` demo tenant with `demo-user1`, etc. +Use `tenant:trial:demo-company` under NetKingdom ADR-0013 and two ordinary demo +accounts initially. Native tenant creation and memberships use the existing +User Engine operator portal; this does not implement Django SSO. Hostname +preference is pending (`demo-vergabe.coulomb.social` proposed). The concrete +prepared binding lives at `railiance-apps/docs/vergabe-demo-company-binding.md`. +No password or personal user list belongs in public work records. The historical vergabe_db is not disposable and must not be overwritten. The user subsequently accepts a 60m application CPU request for one tenant with @@ -102,12 +107,13 @@ status: wait priority: high assignee: the-custodian depends_on: [VERGABE-WP-0019-T03] -blocking_reason: "Await admitted company deployment, immutable release and demonstrated restore; company/host/data selection is pending." +blocking_reason: "Await native demo-company tenant, admitted deployment and demonstrated restore; hostname and operator login remain pending." state_hub_task_id: "db807f96-f5b8-528d-bc33-75a33fbbf1e4" ``` Product acceptance: Bernd Worsch and the named company contact. Verify two -separate ordinary-user accounts can sign in, change passwords, create a tender +separate ordinary-user accounts (`demo-user1` and `demo-user2` initially) can sign +in, change passwords, create a tender and lot, collaborate on tasks, upload/download a document, record a v1 domain approval and submission, and report feedback. Verify anonymous access and a revoked user fail, health remains available, and pod replacement preserves all From 09e2239bc6c228aef01cecd577790c5673ccfafe Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Fri, 11 Sep 2026 21:28:04 +0200 Subject: [PATCH 11/24] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-09-11: - update .custodian-brief.md for vergabe-teilnahme Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- .custodian-brief.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index d129e5a..6a40676 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,17 +2,17 @@ # Custodian Brief — vergabe-teilnahme **Domain:** communication -**Last synced:** 2026-09-11 14:36 UTC +**Last synced:** 2026-09-11 19:28 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Admit the first invited company pilot with protected access and recoverable data -Progress: 2/4 done | workplan_id: `85b5f304-d497-5570-bebf-3a3669ef6a7d` +Progress: 3/5 done | workplan_id: `85b5f304-d497-5570-bebf-3a3669ef6a7d` **Open tasks:** - ! Accept onboarding, collaboration, recovery and support with pilot users `db807f96` - *(wait: Await admitted company deployment, immutable release and demonstrated restore; company/host/data selection is pending.)* + *(wait: Await native demo-company tenant, admitted deployment and demonstrated restore; hostname and operator login remain pending.)* - ► Publish and prove the isolated deployment and recovery contract `9065d5cb` ### Establish verified delivery for the primary customer tender product From 9345a1bb1a92ac0ee3b2dc6443a3d299e6754e70 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 21:29:26 +0200 Subject: [PATCH 12/24] feat: serve isolated companies below a fixed product path Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- WORK-RECORDS.md | 1 + .../2026-09-11-tenant-path-browser.json | 16 +++ docs/tenant-path-deployment.md | 31 +++++ vergabe_teilnahme/apps/accounts/middleware.py | 5 +- .../apps/accounts/test_tenant_path.py | 120 ++++++++++++++++++ vergabe_teilnahme/apps/aufgaben/views.py | 51 ++++---- .../apps/ausschreibungen/views.py | 27 ++-- vergabe_teilnahme/apps/bibliothek/views.py | 30 ++--- vergabe_teilnahme/apps/core/services.py | 4 +- vergabe_teilnahme/apps/lose/views.py | 25 ++-- vergabe_teilnahme/apps/partner/views.py | 13 +- vergabe_teilnahme/settings/base.py | 19 ++- vergabe_teilnahme/settings/paths.py | 12 ++ .../templates/ausschreibungen/dashboard.html | 8 +- .../templates/ausschreibungen/freigaben.html | 2 +- .../templates/core/feld_konfiguration.html | 2 +- .../core/partials/custom_attributes.html | 8 +- .../templates/core/partials/feld_zeile.html | 4 +- vergabe_teilnahme/templates/errors/404.html | 2 +- vergabe_teilnahme/templates/errors/500.html | 2 +- .../feedback/partials/eintrag_zeile.html | 4 +- .../templates/partials/feedback_button.html | 2 +- .../templates/partials/feedback_modal.html | 2 +- .../templates/partials/freigabe_modal.html | 2 +- .../templates/partials/search_results.html | 10 +- .../templates/partials/sidebar.html | 26 ++-- .../templates/partials/topbar.html | 4 +- .../VERGABE-WP-0019-invited-company-pilot.md | 35 ++++- 28 files changed, 350 insertions(+), 117 deletions(-) create mode 100644 docs/evidence/2026-09-11-tenant-path-browser.json create mode 100644 docs/tenant-path-deployment.md create mode 100644 vergabe_teilnahme/apps/accounts/test_tenant_path.py create mode 100644 vergabe_teilnahme/settings/paths.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2d43dd2..e7902a4 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -34,6 +34,7 @@ | task | VERGABE-WP-0019-T02 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T03 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T04 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T05 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VT-WP-0001-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T05 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T07 | done | — | workplans/VT-WP-0001-projektgeruest.md | diff --git a/docs/evidence/2026-09-11-tenant-path-browser.json b/docs/evidence/2026-09-11-tenant-path-browser.json new file mode 100644 index 0000000..9c10003 --- /dev/null +++ b/docs/evidence/2026-09-11-tenant-path-browser.json @@ -0,0 +1,16 @@ +{ + "status": "passed", + "checks": [ + "Edge matches only the exact company prefix", + "Bare company path canonicalizes and anonymous access reaches prefixed login", + "Prefixed CSS and JavaScript load and render the login controls", + "Real CSRF login, cookie scope and dashboard navigation stay within the tenant path", + "Password-change navigation and CSRF logout preserve the prefix", + "Expired HTMX requests demand full prefixed login", + "Mobile login fits and browser has no JavaScript errors" + ], + "synthetic_users": true, + "disposable_sqlite": true, + "local_http_only": true, + "production_edge_admitted": false +} diff --git a/docs/tenant-path-deployment.md b/docs/tenant-path-deployment.md new file mode 100644 index 0000000..d937914 --- /dev/null +++ b/docs/tenant-path-deployment.md @@ -0,0 +1,31 @@ +# Deploy an isolated company below the product hostname + +For the demo pilot, use `https://vergabe-teilnahme.coulomb.social/demo-company/`. +Configure the isolated application with `APP_BASE_PATH=/demo-company` and +`ALLOWED_HOSTS=vergabe-teilnahme.coulomb.social`. The external CSRF origin is +`https://vergabe-teilnahme.coulomb.social`, without a path. + +The edge matches exactly `/demo-company` or the `/demo-company/` path prefix, +redirects the bare path to its trailing-slash form, and strips `/demo-company` +before forwarding. Internal health remains `/health/`. Django's fixed +FORCE_SCRIPT_NAME generates the external URLs. Untrusted forwarded-prefix +headers never select a tenant or override this configuration. Keep static and +private media requests on this same application route. + +Each tenant keeps its own namespace, database, media volume and issue-state +volume. A URL path is a deployment selector, not an authentication claim. +Cookies use company-specific names and paths; cookies and paths are not separate +browser origins. Only this trusted product's instances share the product host. +Django accounts still provide product admission; native platform tenant creation +and membership do not by themselves implement product SSO. + +Omit APP_BASE_PATH for the existing root-path deployment behavior. Nonempty +values must be one lowercase tenant slug prefixed with `/`; traversal, nested +paths and external URLs are refused at startup. + +Validation: 94 existing application tests plus four tenant-path regressions; +Vite asset build; seven local Chromium checks through an exact-path/strip-prefix +edge fixture. Authentication/CSRF, password changes, navigation/HTMX, private +media, cookie scope, assets and mobile layout are covered. These checks use a +disposable SQLite database and synthetic users, and do not establish live DNS, +TLS, database custody or restore readiness. RAPPS-WP-0014 owns those live gates. diff --git a/vergabe_teilnahme/apps/accounts/middleware.py b/vergabe_teilnahme/apps/accounts/middleware.py index 0aaa6ed..8bbbddb 100644 --- a/vergabe_teilnahme/apps/accounts/middleware.py +++ b/vergabe_teilnahme/apps/accounts/middleware.py @@ -1,6 +1,7 @@ from django.contrib.auth.middleware import LoginRequiredMiddleware from django.contrib.auth.views import redirect_to_login from django.http import HttpResponse +from django.urls import reverse from django.utils.cache import add_never_cache_headers @@ -10,12 +11,12 @@ class PilotLoginRequiredMiddleware(LoginRequiredMiddleware): def handle_no_permission(self, request, view_func): if request.headers.get('HX-Request') == 'true': # The original URL may render only a fragment or accept only POST. - login = redirect_to_login('/', self.get_login_url(view_func)) + login = redirect_to_login(reverse('home'), self.get_login_url(view_func)) return HttpResponse(status=401, headers={'HX-Redirect': login.url}) return super().handle_no_permission(request, view_func) def process_response(self, request, response): # Company records and account pages must not survive in shared caches. - if request.path != '/health/': + if request.path_info != '/health/': add_never_cache_headers(response) return response diff --git a/vergabe_teilnahme/apps/accounts/test_tenant_path.py b/vergabe_teilnahme/apps/accounts/test_tenant_path.py new file mode 100644 index 0000000..ab1524b --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/test_tenant_path.py @@ -0,0 +1,120 @@ +"""Regression for an isolated instance mounted below a fixed tenant path.""" +from html.parser import HTMLParser +from urllib.parse import parse_qs, urlsplit + +import pytest +from django.test import Client +from django.urls import get_script_prefix, set_script_prefix + +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung + +pytestmark = pytest.mark.django_db +PREFIX = '/demo-company' + + +@pytest.fixture +def tenant_path(settings): + previous = get_script_prefix() + settings.FORCE_SCRIPT_NAME = PREFIX + settings.APP_BASE_PATH = PREFIX + settings.STATIC_URL = PREFIX + '/static/' + settings.MEDIA_URL = PREFIX + '/media/' + settings.SESSION_COOKIE_PATH = settings.CSRF_COOKIE_PATH = PREFIX + '/' + settings.SESSION_COOKIE_NAME = 'vergabe_demo_company_sessionid' + settings.CSRF_COOKIE_NAME = 'vergabe_demo_company_csrftoken' + set_script_prefix(PREFIX) + yield settings + set_script_prefix(previous) + + +class LocalLinks(HTMLParser): + def __init__(self): + super().__init__() + self.links = [] + + def handle_starttag(self, tag, attrs): + for name, value in attrs: + if name in {'href', 'src', 'action', 'hx-get', 'hx-post', 'hx-delete'} and value: + if value.startswith('/'): + self.links.append(value) + + +def assert_prefixed(content): + parser = LocalLinks() + parser.feed(content.decode()) + assert parser.links + assert all(link.startswith(PREFIX + '/') for link in parser.links), parser.links + + +def test_anonymous_and_expired_fragments_stay_in_tenant_path(client, tenant_path): + response = client.get('/ausschreibungen/', HTTP_X_FORWARDED_PREFIX='/other-company') + assert response.status_code == 302 + assert urlsplit(response.url).path == PREFIX + '/accounts/login/' + assert parse_qs(urlsplit(response.url).query)['next'] == [PREFIX + '/ausschreibungen/'] + response = client.get('/suche/?q=private', HTTP_HX_REQUEST='true') + assert response.status_code == 401 + assert urlsplit(response['HX-Redirect']).path == PREFIX + '/accounts/login/' + assert parse_qs(urlsplit(response['HX-Redirect']).query)['next'] == [PREFIX + '/'] + assert client.get('/health/').json() == {'status': 'ok'} + + +def test_login_cookie_csrf_logout_and_external_redirect(client, mitarbeiter, tenant_path): + client = Client(enforce_csrf_checks=True) + login = client.get('/accounts/login/') + assert_prefixed(login.content) + csrf = tenant_path.CSRF_COOKIE_NAME + assert client.cookies[csrf]['path'] == PREFIX + '/' + credentials = {'username': mitarbeiter.username, 'password': 'testpass'} + assert client.post('/accounts/login/', credentials).status_code == 403 + credentials['csrfmiddlewaretoken'] = client.cookies[csrf].value + credentials['next'] = 'https://untrusted.example/private' + response = client.post('/accounts/login/', credentials) + assert response.status_code == 302 + assert response.url == PREFIX + '/' + assert client.cookies[tenant_path.SESSION_COOKIE_NAME]['path'] == PREFIX + '/' + assert client.get('/accounts/logout/').status_code == 405 + assert client.post('/accounts/logout/').status_code == 403 + response = client.post('/accounts/logout/', {'csrfmiddlewaretoken': client.cookies[csrf].value}) + assert response.url == PREFIX + '/accounts/login/' + assert client.cookies[tenant_path.SESSION_COOKIE_NAME]['path'] == PREFIX + '/' + assert client.get('/ausschreibungen/').status_code == 302 + + +def test_member_navigation_fragments_and_password_change(client, mitarbeiter, tenant_path): + tender = Ausschreibung.objects.create(titel='Path test', ausschreiber='Demo') + client.force_login(mitarbeiter) + for path in [ + '/ausschreibungen/', f'/ausschreibungen/{tender.pk}/', + '/aufgaben/', '/partner/subunternehmer/', '/bibliothek/nachweise/', + '/accounts/password-change/', + ]: + response = client.get(path) + assert response.status_code == 200, path + assert_prefixed(response.content) + fragment = client.get('/suche/?q=Path', HTTP_HX_REQUEST='true') + assert fragment.status_code == 200 + assert_prefixed(fragment.content) + response = client.post('/accounts/password-change/', { + 'old_password': 'testpass', + 'new_password1': 'Path-pilot-password-984!', + 'new_password2': 'Path-pilot-password-984!', + }) + assert response.status_code == 302 + assert response.url.startswith(PREFIX + '/') + assert client.get('/ausschreibungen/').status_code == 200 + + +def test_private_media_remains_authenticated_under_prefix( + client, mitarbeiter, tenant_path, tmp_path, +): + tenant_path.MEDIA_ROOT = tmp_path + (tmp_path / 'document.pdf').write_bytes(b'%PDF-private-demo') + anonymous = client.get('/media/document.pdf') + assert anonymous.status_code == 302 + assert urlsplit(anonymous.url).path == PREFIX + '/accounts/login/' + client.force_login(mitarbeiter) + response = client.get('/media/document.pdf') + assert response.status_code == 200 + assert b''.join(response.streaming_content) == b'%PDF-private-demo' + assert response['Content-Disposition'] == 'attachment; filename="document.pdf"' + assert 'no-store' in response['Cache-Control'] diff --git a/vergabe_teilnahme/apps/aufgaben/views.py b/vergabe_teilnahme/apps/aufgaben/views.py index 50804e9..70de1f6 100644 --- a/vergabe_teilnahme/apps/aufgaben/views.py +++ b/vergabe_teilnahme/apps/aufgaben/views.py @@ -1,12 +1,12 @@ from datetime import date, timedelta +from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung -from django.http import HttpResponse - from .issue_backends import gitea_configured as _gitea_configured from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue @@ -65,8 +65,8 @@ def aufgaben_liste(request, ausschreibung_id=None): if ausschreibung: breadcrumbs = [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, {'label': 'Aufgaben', 'url': None}, ] else: @@ -119,9 +119,9 @@ def aufgabe_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Aufgabe anlegen', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': 'Neu', 'url': None}, ], }) @@ -142,9 +142,9 @@ def aufgabe_bearbeiten(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'titel': 'Aufgabe bearbeiten', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': aufgabe.titel[:50], 'url': None}, ], }) @@ -161,9 +161,9 @@ def aufgabe_loeschen(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': 'Löschen', 'url': None}, ], }) @@ -176,9 +176,9 @@ def aufgabe_detail(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': aufgabe.titel[:50], 'url': None}, ], }) @@ -237,17 +237,18 @@ def bieterfragen_liste(request, ausschreibung_id): 'mitarbeiter': Mitarbeiter.objects.all(), 'current_status': status_filter or '', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, {'label': 'Bieterfragen', 'url': None}, ], }) def bieterfrage_neu(request, ausschreibung_id): - from .forms import BieterfragenForm from vergabe_teilnahme.apps.lose.models import Anforderung + from .forms import BieterfragenForm + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) initial = {} @@ -275,9 +276,9 @@ def bieterfrage_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Bieterfrage anlegen', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Bieterfragen', 'url': reverse('ausschreibungen:bieterfragen:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': 'Neu', 'url': None}, ], }) @@ -290,9 +291,9 @@ def bieterfrage_detail(request, ausschreibung_id, pk): 'bieterfrage': bieterfrage, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Bieterfragen', 'url': reverse('ausschreibungen:bieterfragen:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': str(bieterfrage)[:50], 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/ausschreibungen/views.py b/vergabe_teilnahme/apps/ausschreibungen/views.py index a4c0d7c..f6e74e8 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/views.py +++ b/vergabe_teilnahme/apps/ausschreibungen/views.py @@ -1,6 +1,7 @@ from datetime import date, timedelta from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone from vergabe_teilnahme.apps.accounts.models import Mitarbeiter @@ -101,7 +102,7 @@ def ausschreibung_neu(request): 'historisch': historisch, 'titel': 'Neue Ausschreibung', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -111,7 +112,11 @@ def ausschreibung_detail(request, pk): from django.db.models import Count, Q from vergabe_teilnahme.apps.aufgaben.models import Aufgabe - from vergabe_teilnahme.apps.core.services import aufgaben_score, build_phase_nav, get_deadline_warnings + from vergabe_teilnahme.apps.core.services import ( + aufgaben_score, + build_phase_nav, + get_deadline_warnings, + ) from vergabe_teilnahme.apps.lose.models import Los a = get_object_or_404(Ausschreibung, pk=pk) @@ -136,7 +141,7 @@ def ausschreibung_detail(request, pk): 'warnungen': get_deadline_warnings(a), 'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)), 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, {'label': a.titel, 'url': None}, ], } @@ -156,8 +161,8 @@ def ausschreibung_bearbeiten(request, pk): 'titel': 'Ausschreibung bearbeiten', 'ausschreibung': a, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -197,8 +202,8 @@ def ausschreibung_entscheidung(request, pk): 'regelergebnis': entscheidungsregel_auswertung(a), 'ausschlusskriterien_nicht_erfuellbar': ausschlusskriterien, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, {'label': 'Teilnahmeentscheidung', 'url': None}, ], } @@ -227,8 +232,8 @@ def freigaben_uebersicht(request, pk): 'ct_id': ct.pk, 'freigabe_typ_choices': Freigabe.TYP_CHOICES, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, {'label': 'Freigaben', 'url': None}, ], } @@ -245,8 +250,8 @@ def ausschreibung_archivieren(request, pk): return render(request, 'ausschreibungen/archivieren_confirm.html', { 'ausschreibung': a, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, {'label': 'Archivieren', 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/bibliothek/views.py b/vergabe_teilnahme/apps/bibliothek/views.py index dc96e77..2db4666 100644 --- a/vergabe_teilnahme/apps/bibliothek/views.py +++ b/vergabe_teilnahme/apps/bibliothek/views.py @@ -3,12 +3,12 @@ from datetime import date, timedelta from django import forms from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from vergabe_teilnahme.apps.accounts.models import Mitarbeiter from .models import Entscheidungsregel, Leistungsblatt, Nachweis, Referenz - # ── Forms ───────────────────────────────────────────────────────────────────── class NachweisForm(forms.ModelForm): @@ -173,7 +173,7 @@ def nachweis_neu(request): return render(request, 'bibliothek/nachweis_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, + {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -184,7 +184,7 @@ def nachweis_detail(request, pk): return render(request, 'bibliothek/nachweis_detail.html', { 'nachweis': nachweis, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, + {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, {'label': nachweis.titel, 'url': None}, ], }) @@ -205,8 +205,8 @@ def nachweis_bearbeiten(request, pk): 'form': form, 'nachweis': nachweis, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, - {'label': nachweis.titel, 'url': f'/bibliothek/nachweise/{pk}/'}, + {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, + {'label': nachweis.titel, 'url': reverse('bibliothek:nachweis_detail', kwargs={'pk': pk})}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -237,8 +237,8 @@ def nachweis_neue_version(request, pk): 'nachweis': alter_nachweis, 'neue_version': True, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, - {'label': alter_nachweis.titel, 'url': f'/bibliothek/nachweise/{pk}/'}, + {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, + {'label': alter_nachweis.titel, 'url': reverse('bibliothek:nachweis_detail', kwargs={'pk': pk})}, {'label': 'Neue Version', 'url': None}, ], }) @@ -279,7 +279,7 @@ def referenz_neu(request): return render(request, 'bibliothek/referenz_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, + {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -290,7 +290,7 @@ def referenz_detail(request, pk): return render(request, 'bibliothek/referenz_detail.html', { 'ref': ref, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, + {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, {'label': ref.referenztitel, 'url': None}, ], }) @@ -311,8 +311,8 @@ def referenz_bearbeiten(request, pk): 'form': form, 'ref': ref, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, - {'label': ref.referenztitel, 'url': f'/bibliothek/referenzen/{pk}/'}, + {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, + {'label': ref.referenztitel, 'url': reverse('bibliothek:referenz_detail', kwargs={'pk': pk})}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -365,7 +365,7 @@ def leistungsblatt_neu(request): return render(request, 'bibliothek/leistungsblatt_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Leistungsblätter', 'url': '/bibliothek/leistungsblaetter/'}, + {'label': 'Leistungsblätter', 'url': reverse('bibliothek:leistungsblaetter_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -386,7 +386,7 @@ def leistungsblatt_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Leistungsblätter', 'url': '/bibliothek/leistungsblaetter/'}, + {'label': 'Leistungsblätter', 'url': reverse('bibliothek:leistungsblaetter_liste')}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -415,7 +415,7 @@ def entscheidungsregel_neu(request): return render(request, 'bibliothek/entscheidungsregel_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Entscheidungsregeln', 'url': '/bibliothek/entscheidungsregeln/'}, + {'label': 'Entscheidungsregeln', 'url': reverse('bibliothek:entscheidungsregeln_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -436,7 +436,7 @@ def entscheidungsregel_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Entscheidungsregeln', 'url': '/bibliothek/entscheidungsregeln/'}, + {'label': 'Entscheidungsregeln', 'url': reverse('bibliothek:entscheidungsregeln_liste')}, {'label': 'Bearbeiten', 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/core/services.py b/vergabe_teilnahme/apps/core/services.py index a874e80..481c8ee 100644 --- a/vergabe_teilnahme/apps/core/services.py +++ b/vergabe_teilnahme/apps/core/services.py @@ -1,6 +1,8 @@ from datetime import date from decimal import Decimal +from django.urls import reverse + PHASEN = [ (1, 'Recherche & Unterlagen'), (2, 'Teilnahmeentscheidung'), @@ -35,7 +37,7 @@ def aufgaben_score(qs): def build_phase_nav(ausschreibung, current_url=''): from vergabe_teilnahme.apps.aufgaben.models import Aufgabe aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1) - base = f'/ausschreibungen/{ausschreibung.pk}' + base = reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung.pk}).rstrip('/') phase_urls = { 1: f'{base}/', 2: f'{base}/entscheidung/', diff --git a/vergabe_teilnahme/apps/lose/views.py b/vergabe_teilnahme/apps/lose/views.py index 64cb308..dfb0296 100644 --- a/vergabe_teilnahme/apps/lose/views.py +++ b/vergabe_teilnahme/apps/lose/views.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.bibliothek.models import Nachweis @@ -13,8 +14,8 @@ def _is_htmx(request): def _ausschreibung_breadcrumbs(ausschreibung, *extra): crumbs = [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung.pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung.pk})}, ] for label, url in extra: crumbs.append({'label': label, 'url': url}) @@ -65,7 +66,7 @@ def los_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Los hinzufügen', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), ('Neu', None)), }) @@ -79,7 +80,7 @@ def los_detail(request, ausschreibung_id, los_pk): 'los': los, 'anforderungen': anforderungen, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), (str(los), None)), }) @@ -97,8 +98,8 @@ def los_bearbeiten(request, ausschreibung_id, los_pk): 'los': los, 'titel': 'Los bearbeiten', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), - (str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), + ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), + (str(los), reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': ausschreibung_id, 'los_pk': los_pk})), ('Bearbeiten', None)), }) @@ -113,8 +114,8 @@ def los_loeschen(request, ausschreibung_id, los_pk): 'ausschreibung': ausschreibung, 'los': los, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), - (str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), + ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), + (str(los), reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': ausschreibung_id, 'los_pk': los_pk})), ('Löschen', None)), }) @@ -187,7 +188,7 @@ def anforderung_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Anforderung anlegen', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), + ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), ('Neu', None)), }) @@ -201,7 +202,7 @@ def anforderung_detail(request, ausschreibung_id, pk): 'anforderung': anforderung, 'aufgaben': aufgaben, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), + ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), (anforderung.titel[:50], None)), }) @@ -220,8 +221,8 @@ def anforderung_bearbeiten(request, ausschreibung_id, pk): 'anforderung': anforderung, 'titel': 'Anforderung bearbeiten', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), - (anforderung.titel[:50], f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/{pk}/'), + ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), + (anforderung.titel[:50], reverse('ausschreibungen:lose:anforderung_detail', kwargs={'ausschreibung_id': ausschreibung_id, 'pk': pk})), ('Bearbeiten', None)), }) diff --git a/vergabe_teilnahme/apps/partner/views.py b/vergabe_teilnahme/apps/partner/views.py index fd65395..0dfefd1 100644 --- a/vergabe_teilnahme/apps/partner/views.py +++ b/vergabe_teilnahme/apps/partner/views.py @@ -1,6 +1,7 @@ from django import forms from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.lose.models import Los @@ -97,7 +98,7 @@ def subunternehmer_neu(request): return render(request, 'partner/subunternehmer_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, + {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -113,7 +114,7 @@ def subunternehmer_detail(request, pk): 'sub': sub, 'zuordnungen': zuordnungen, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, + {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, {'label': sub.name, 'url': None}, ], }) @@ -134,8 +135,8 @@ def subunternehmer_bearbeiten(request, pk): 'form': form, 'sub': sub, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, - {'label': sub.name, 'url': f'/partner/subunternehmer/{pk}/'}, + {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, + {'label': sub.name, 'url': reverse('partner:su_detail', kwargs={'pk': pk})}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -209,7 +210,7 @@ def dienstleistertyp_neu(request): return render(request, 'partner/dienstleistertyp_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'}, + {'label': 'Dienstleistertypen', 'url': reverse('partner:dt_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -230,7 +231,7 @@ def dienstleistertyp_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'}, + {'label': 'Dienstleistertypen', 'url': reverse('partner:dt_liste')}, {'label': 'Bearbeiten', 'url': None}, ], }) diff --git a/vergabe_teilnahme/settings/base.py b/vergabe_teilnahme/settings/base.py index 29105aa..dc588b5 100644 --- a/vergabe_teilnahme/settings/base.py +++ b/vergabe_teilnahme/settings/base.py @@ -3,6 +3,8 @@ from pathlib import Path import dj_database_url from decouple import config +from .paths import application_path + BASE_DIR = Path(__file__).resolve().parent.parent.parent SECRET_KEY = config('SECRET_KEY', default='django-insecure-change-me') @@ -69,9 +71,20 @@ DATABASES = { AUTH_USER_MODEL = 'accounts.Mitarbeiter' LOGIN_URL = 'accounts:login' -LOGIN_REDIRECT_URL = '/' +LOGIN_REDIRECT_URL = 'home' LOGOUT_REDIRECT_URL = 'accounts:login' +# The edge strips this exact prefix before forwarding to this isolated instance. +# Django uses SCRIPT_NAME for URL generation; tenant selection stays with the +# admitted deployment/database binding, not arbitrary forwarded headers. +APP_BASE_PATH = application_path(config('APP_BASE_PATH', default='')) +FORCE_SCRIPT_NAME = APP_BASE_PATH or None +SESSION_COOKIE_PATH = CSRF_COOKIE_PATH = APP_BASE_PATH + '/' +if APP_BASE_PATH: + _cookie_prefix = 'vergabe_' + APP_BASE_PATH[1:].replace('-', '_') + SESSION_COOKIE_NAME = _cookie_prefix + '_sessionid' + CSRF_COOKIE_NAME = _cookie_prefix + '_csrftoken' + DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' AUTH_PASSWORD_VALIDATORS = [ @@ -86,11 +99,11 @@ TIME_ZONE = 'Europe/Berlin' USE_I18N = True USE_TZ = True -STATIC_URL = '/static/' +STATIC_URL = APP_BASE_PATH + '/static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [BASE_DIR / 'static'] -MEDIA_URL = '/media/' +MEDIA_URL = APP_BASE_PATH + '/media/' MEDIA_ROOT = BASE_DIR / 'media' MAX_UPLOAD_SIZE = config('MAX_UPLOAD_SIZE', default=52428800, cast=int) diff --git a/vergabe_teilnahme/settings/paths.py b/vergabe_teilnahme/settings/paths.py new file mode 100644 index 0000000..c05ce4b --- /dev/null +++ b/vergabe_teilnahme/settings/paths.py @@ -0,0 +1,12 @@ +import re + +from django.core.exceptions import ImproperlyConfigured + + +def application_path(value): + """A deployment-owned tenant path, never a value taken from request headers.""" + if value in ('', '/'): + return '' + if not re.fullmatch(r'/[a-z0-9]+(?:-[a-z0-9]+)*', value): + raise ImproperlyConfigured('APP_BASE_PATH must be empty or /lowercase-tenant-slug') + return value diff --git a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html index 44034be..aaa2edc 100644 --- a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html +++ b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html @@ -18,7 +18,7 @@
    {% for a in kritische_fristen %}
  • - {{ a.titel }} + {{ a.titel }} {{ a.abgabe_bis|date:"d.m.Y H:i" }} @@ -42,7 +42,7 @@
      {% for a in ohne_entscheidung %}
    • - {{ a.titel }} + {{ a.titel }} seit {{ a.erstellt_am|date:"d.m.Y" }}
    • {% endfor %} @@ -64,7 +64,7 @@
        {% for aufgabe in ueberfaellige_aufgaben %}
      • - + {{ aufgabe.titel }} {{ aufgabe.frist|date:"d.m.Y" }} @@ -88,7 +88,7 @@
          {% for a in laufende_ausschreibungen %}
        • - {{ a.titel }} + {{ a.titel }} {% status_badge a.get_status_display a.status %}
        • {% endfor %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/freigaben.html b/vergabe_teilnahme/templates/ausschreibungen/freigaben.html index 9fa4e66..37c2184 100644 --- a/vergabe_teilnahme/templates/ausschreibungen/freigaben.html +++ b/vergabe_teilnahme/templates/ausschreibungen/freigaben.html @@ -4,7 +4,7 @@ {% block content %}

          Freigaben: {{ ausschreibung.titel }}

          -
          diff --git a/vergabe_teilnahme/templates/core/feld_konfiguration.html b/vergabe_teilnahme/templates/core/feld_konfiguration.html index 3a24ab6..a34b9f5 100644 --- a/vergabe_teilnahme/templates/core/feld_konfiguration.html +++ b/vergabe_teilnahme/templates/core/feld_konfiguration.html @@ -7,7 +7,7 @@
          {% for et in entity_types %} - {{ et }} diff --git a/vergabe_teilnahme/templates/core/partials/custom_attributes.html b/vergabe_teilnahme/templates/core/partials/custom_attributes.html index b899bec..025fd1d 100644 --- a/vergabe_teilnahme/templates/core/partials/custom_attributes.html +++ b/vergabe_teilnahme/templates/core/partials/custom_attributes.html @@ -7,7 +7,7 @@
          -
          @@ -39,19 +39,19 @@ {{ attr.value|default:"—" }} {{ attr.data_type }}
          - {% csrf_token %} -
          {% csrf_token %}
          -
          {% csrf_token %} diff --git a/vergabe_teilnahme/templates/core/partials/feld_zeile.html b/vergabe_teilnahme/templates/core/partials/feld_zeile.html index aef1a66..1a693de 100644 --- a/vergabe_teilnahme/templates/core/partials/feld_zeile.html +++ b/vergabe_teilnahme/templates/core/partials/feld_zeile.html @@ -1,7 +1,7 @@ {{ field_name }} - @@ -14,7 +14,7 @@
          -
          {% csrf_token %} diff --git a/vergabe_teilnahme/templates/errors/404.html b/vergabe_teilnahme/templates/errors/404.html index 23a62d1..27a47a6 100644 --- a/vergabe_teilnahme/templates/errors/404.html +++ b/vergabe_teilnahme/templates/errors/404.html @@ -5,6 +5,6 @@

          404

          Seite nicht gefunden

          Die angeforderte Seite existiert nicht oder wurde verschoben.

          - Zur Übersicht + Zur Übersicht
          {% endblock %} diff --git a/vergabe_teilnahme/templates/errors/500.html b/vergabe_teilnahme/templates/errors/500.html index eb063de..6b78af4 100644 --- a/vergabe_teilnahme/templates/errors/500.html +++ b/vergabe_teilnahme/templates/errors/500.html @@ -5,6 +5,6 @@

          500

          Interner Serverfehler

          Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es später erneut.

          - Zur Übersicht + Zur Übersicht
          {% endblock %} diff --git a/vergabe_teilnahme/templates/feedback/partials/eintrag_zeile.html b/vergabe_teilnahme/templates/feedback/partials/eintrag_zeile.html index f9b3e29..9896446 100644 --- a/vergabe_teilnahme/templates/feedback/partials/eintrag_zeile.html +++ b/vergabe_teilnahme/templates/feedback/partials/eintrag_zeile.html @@ -33,7 +33,7 @@ - {% csrf_token %} @@ -51,7 +51,7 @@ {# edit row #} - {% csrf_token %} diff --git a/vergabe_teilnahme/templates/partials/feedback_button.html b/vergabe_teilnahme/templates/partials/feedback_button.html index 2969bf5..84e5f1f 100644 --- a/vergabe_teilnahme/templates/partials/feedback_button.html +++ b/vergabe_teilnahme/templates/partials/feedback_button.html @@ -1,4 +1,4 @@ - @@ -27,10 +27,10 @@ @@ -40,17 +40,17 @@ - Aufgaben - Marktbegleiter - Feedback-Backlog + Aufgaben + Marktbegleiter + Feedback-Backlog {% if request.user.is_staff %} - Administration + Administration {% endif %} diff --git a/vergabe_teilnahme/templates/partials/topbar.html b/vergabe_teilnahme/templates/partials/topbar.html index 6e3be79..e16b4c3 100644 --- a/vergabe_teilnahme/templates/partials/topbar.html +++ b/vergabe_teilnahme/templates/partials/topbar.html @@ -7,9 +7,9 @@ - Vergabe Teilnahme + Vergabe Teilnahme - Date: Sat, 12 Sep 2026 01:01:11 +0200 Subject: [PATCH 13/24] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-09-12: - update .custodian-brief.md for vergabe-teilnahme Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- .custodian-brief.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 6a40676..119fa96 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,18 +2,19 @@ # Custodian Brief — vergabe-teilnahme **Domain:** communication -**Last synced:** 2026-09-11 19:28 UTC +**Last synced:** 2026-09-11 23:01 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Admit the first invited company pilot with protected access and recoverable data -Progress: 3/5 done | workplan_id: `85b5f304-d497-5570-bebf-3a3669ef6a7d` +Progress: 3/6 done | workplan_id: `85b5f304-d497-5570-bebf-3a3669ef6a7d` **Open tasks:** - ! Accept onboarding, collaboration, recovery and support with pilot users `db807f96` - *(wait: Await native demo-company tenant, admitted deployment and demonstrated restore; hostname and operator login remain pending.)* + *(wait: Native demo-company exists and hostname/path is selected. Await identity-provisioner service-login repair, admitted application deployment and demonstrated restore.)* - ► Publish and prove the isolated deployment and recovery contract `9065d5cb` +- · Connect identity setup to the company's welcome and sign-in page `455b33f9` ### Establish verified delivery for the primary customer tender product Progress: 2/3 done | workplan_id: `27a95f7b-cec4-50ca-8383-c1c95d996217` From 4175709024d26ed0c68c7e7c7fd476d0d87a31e7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 01:08:33 +0200 Subject: [PATCH 14/24] plan: connect identity setup to the tenant welcome page Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- WORK-RECORDS.md | 1 + .../VERGABE-WP-0019-invited-company-pilot.md | 48 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index e7902a4..753ce1a 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -35,6 +35,7 @@ | task | VERGABE-WP-0019-T03 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T04 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T05 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T06 | todo | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VT-WP-0001-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T05 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T07 | done | — | workplans/VT-WP-0001-projektgeruest.md | diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index f21288c..1e107c7 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -8,7 +8,7 @@ status: active owner: the-custodian topic_slug: vergabe-teilnahme created: "2026-09-11" -updated: "2026-09-11" +updated: "2026-09-12" related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001, CUST-WP-0071] state_hub_workstream_id: "85b5f304-d497-5570-bebf-3a3669ef6a7d" --- @@ -108,7 +108,7 @@ status: wait priority: high assignee: the-custodian depends_on: [VERGABE-WP-0019-T03] -blocking_reason: "Native demo-company exists and hostname/path is selected. Await identity-provisioner service-login repair, admitted application deployment and demonstrated restore." +blocking_reason: "Native tenant/user/password setup is verified. Await admitted application placement, tenant welcome/sign-in connection and demonstrated restore." state_hub_task_id: "db807f96-f5b8-528d-bc33-75a33fbbf1e4" ``` @@ -155,3 +155,47 @@ login failed in identity-provisioner at LLDAP service authentication (HTTP 401). Its existing credential was reloaded without rotation; the connection still fails. NetKingdom tracks that owner-credential reconciliation before identity provisioning retries. No passwords or private addresses enter this record. + + +2026-09-12 native follow-through: the credential repair is verified, the operator +reports successful user password setup, and live read-only User Engine evidence +shows three memberships with one linked directory identity. This supersedes the +preceding service-login failure. It does not create a Django account or session. + +## Connect identity setup to the company's welcome and sign-in page + +```task +id: VERGABE-WP-0019-T06 +status: todo +priority: high +assignee: the-custodian +state_hub_task_id: "455b33f9-163a-5389-86a3-04000c32d176" +``` + +The operator successfully sets a user's directory password, but the provider ends +on a generic Password set page. The user requests a welcome page for the tenant +on the application. Target the selected +https://vergabe-teilnahme.coulomb.social/demo-company/ entry point. + +Finish RAPPS-WP-0014-T02's real application route/data/placement, then preserve +company and intended recipient through the invitation/password-setup handoff +using exact allowed HTTPS return locations. Show a tenant-specific welcome page +and a clear sign-in action. Connect application authentication to the existing +NetKingdom identity contract, with issuer, signature, audience, expiry and exact +tenant validation, and a stable subject-to-local-account mapping. Keep ordinary +users non-staff and reject wrong-tenant identities. No password copying or +operator-session impersonation is an acceptable bridge. + +The administrator may create another user's setup link while signed in as +platform-root. Completing that link must not turn the operator's existing portal +or shared SSO session into the recipient's application session. Handle the +explicit fresh-user sign-in/account-switch boundary; USER-WP-0025-T03 retains +complete provider sign-out coordination. Preserve the single-company database, +media and issue-state isolation and the accepted 60m allocation. + +Verify a fresh invited user follows setup → company welcome → authenticated +application, sees the correct company, and can enter the pilot workflow. Verify +invalid/expired setup returns, unapproved external return URLs, wrong-tenant +identities and operator-session confusion fail safely. Keep setup tokens, +credentials and personal addresses out of work records. The earlier manual +Django login gate remains an interim product capability, not SSO proof. From 8be281025bf57a7aefd1fe98a7e060f2173df5ef Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 02:43:31 +0200 Subject: [PATCH 15/24] Add company welcome and verified NetKingdom sign-in Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- Dockerfile | 1 + docs/netkingdom-sign-in.md | 74 +++++ pyproject.toml | 1 + uv.lock | 160 +++++++++++ .../apps/accounts/context_processors.py | 7 + vergabe_teilnahme/apps/accounts/middleware.py | 28 +- .../accounts/migrations/0003_oidcidentity.py | 45 +++ vergabe_teilnahme/apps/accounts/models.py | 10 + vergabe_teilnahme/apps/accounts/oidc.py | 210 ++++++++++++++ vergabe_teilnahme/apps/accounts/test_oidc.py | 265 ++++++++++++++++++ vergabe_teilnahme/apps/accounts/urls.py | 39 ++- vergabe_teilnahme/apps/accounts/views.py | 138 ++++++++- vergabe_teilnahme/settings/base.py | 9 + .../templates/accounts/confirm.html | 12 + .../templates/accounts/welcome.html | 12 + .../templates/partials/topbar.html | 4 +- .../VERGABE-WP-0019-invited-company-pilot.md | 52 +++- 17 files changed, 1044 insertions(+), 23 deletions(-) create mode 100644 docs/netkingdom-sign-in.md create mode 100644 vergabe_teilnahme/apps/accounts/context_processors.py create mode 100644 vergabe_teilnahme/apps/accounts/migrations/0003_oidcidentity.py create mode 100644 vergabe_teilnahme/apps/accounts/oidc.py create mode 100644 vergabe_teilnahme/apps/accounts/test_oidc.py create mode 100644 vergabe_teilnahme/templates/accounts/confirm.html create mode 100644 vergabe_teilnahme/templates/accounts/welcome.html diff --git a/Dockerfile b/Dockerfile index e741bbe..2c071ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,4 +119,5 @@ CMD ["gunicorn", "vergabe_teilnahme.wsgi:application", \ "--bind", "0.0.0.0:8000", \ "--workers", "3", \ "--access-logfile", "-", \ + "--access-logformat", "%(h)s %(m)s %(U)s %(s)s %(L)s", \ "--error-logfile", "-"] diff --git a/docs/netkingdom-sign-in.md b/docs/netkingdom-sign-in.md new file mode 100644 index 0000000..a0f4250 --- /dev/null +++ b/docs/netkingdom-sign-in.md @@ -0,0 +1,74 @@ +# Company welcome and NetKingdom sign-in + +VERGABE-WP-0019-T06. The isolated company instance consumes Authorization Code +with S256 PKCE and a browser-bound state and nonce. It discovers endpoints from +the configured HTTPS issuer, pins RS256, verifies signature/issuer/audience, +time and nonce, and uses a unique `(issuer, subject)` record for the local user. +It never merges accounts by email or directory display name. + +Admission requires the exact configured tenant, `tenant_source: directory`, a +human principal and the signed `:users` membership group. Suspended, +platform and emergency identities fail. This is the existing invited-company +pilot rule: admitted members collaborate on all company tenders. Tenant-admin +claims do not grant Django staff or superuser rights. New local users have no +usable local password. Local deactivation immediately blocks existing sessions; +provider-side revocation is bounded by the product's absolute five-minute +session maximum (or the token expiry, whichever comes first). + +The welcome page never consumes password-setup tokens. A CSRF-protected sign-in +action clears the current product session and requests `prompt=login`. A valid +callback displays the verified account and requires another CSRF-protected +confirmation before creating a product session. External `next` and `return_to` +parameters are not used. The callback redirects to a clean URL before displaying +identity. Gunicorn access logging records the URL path without query strings. +Passwords and bearer tokens are not persisted in the product. + +## Exact demo binding + +Enable only with the registered client and the verified KeyCape fresh-login +release. The currently deployed `dcebd46` issuer lacks `tenant_source` and does +not propagate fresh-login requests to Authelia; this product fails closed on it. + +``` +NETKINGDOM_ENABLED=true +NETKINGDOM_ISSUER=https://kc.coulomb.social +NETKINGDOM_CLIENT_ID=vergabe-demo-company +NETKINGDOM_CALLBACK=https://vergabe-teilnahme.coulomb.social/demo-company/accounts/oidc/callback/ +NETKINGDOM_TENANT=tenant:trial:demo-company +COMPANY_DISPLAY_NAME=Demo Company +``` + +Use a public client with only `openid profile groups`, `authorization_code`, +and the exact callback above. Do not add a client-declared tenant or a weaker +MFA override. Keep the existing provider MFA policy. The application namespace +requires narrowly scoped HTTPS egress to the issuer; its existing policy only +allows database traffic and DNS. + +NetKingdom's password setup maps `tenant:trial:demo-company` to the fixed HTTPS +company entry in `PASSWORD_SETUP_TENANT_RETURNS`. That return is stored inside +the setup grant at issuance and released only after successful consumption. +There is no browser-supplied return address, recipient credential transfer, +automatic product login or modification to the operator's portal session. +Existing setup grants have no new destination; issue a fresh link if needed. + +## Release and acceptance + +Run application tests, assets build and migration drift checks, publish the +exact source, and pin its digest in the existing Railiance pilot values. +Migration 0003 adds only the unique identity mapping table. Keep the accepted +60m CPU / 256Mi memory request and both data claims. Disabling +NETKINGDOM_ENABLED restores the interim local login; OIDC-created users still +have unusable local passwords, so rollback does not manufacture credentials. +Keep the added table when reverting an application image. + +KeyCape's current main also contains previously accepted startup and token +issuance changes after deployed source dcebd46; validate the live registration +configuration before upgrading (key-cape/docs/operations.md). Existing login +attempts are process-local and are lost on its single-replica Recreate rollout. +Password-setup links are likewise process-local and expire on provisioner restart. + +Native acceptance still requires the actual invited user to complete setup, +fresh sign-in and any provider-required MFA, confirm their account, and enter +the company workflow. Do not substitute an operator session. Recovery and +two-user workflow acceptance remain the existing RAPPS-WP-0014-T03 and +VERGABE-WP-0019-T04 tasks. diff --git a/pyproject.toml b/pyproject.toml index 687832b..189313a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "dj-database-url>=2.1", "issue-core>=0.2,<0.3", "gunicorn>=22.0", + "PyJWT[crypto]>=2.10,<3", ] [tool.uv.sources] diff --git a/uv.lock b/uv.lock index 231296d..b4ad5e5 100644 --- a/uv.lock +++ b/uv.lock @@ -61,6 +61,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821 }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719 }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799 }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389 }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249 }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775 }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822 }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232 }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597 }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292 }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919 }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093 }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248 }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908 }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805 }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764 }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722 }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369 }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175 }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670 }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824 }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148 }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564 }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263 }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688 }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078 }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064 }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720 }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964 }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962 }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328 }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985 }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530 }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525 }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053 }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213 }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682 }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949 }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947 }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504 }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259 }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864 }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538 }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688 }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803 }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763 }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688 }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868 }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104 }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402 }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043 }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737 }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933 }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002 }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271 }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919 }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529 }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630 }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134 }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197 }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683 }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897 }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935 }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464 }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262 }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779 }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520 }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673 }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835 }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705 }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539 }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707 }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772 }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360 }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -239,6 +324,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346 }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153 }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133 }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478 }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726 }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524 }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720 }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866 }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028 }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405 }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230 }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596 }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082 }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525 }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817 }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300 }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039 }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388 }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293 }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031 }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344 }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201 }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023 }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362 }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247 }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806 }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307 }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900 }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357 }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474 }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862 }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942 }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347 }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050 }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955 }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694 }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413 }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355 }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429 }, +] + [[package]] name = "dj-database-url" version = "3.1.2" @@ -571,6 +706,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849 }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -580,6 +724,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896 }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -748,6 +906,7 @@ dependencies = [ { name = "gunicorn" }, { name = "issue-core" }, { name = "psycopg", extra = ["binary"] }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-decouple" }, { name = "whitenoise" }, ] @@ -770,6 +929,7 @@ requires-dist = [ { name = "gunicorn", specifier = ">=22.0" }, { name = "issue-core", specifier = ">=0.2,<0.3", index = "https://forgejo.coulomb.social/api/packages/coulomb/pypi/simple/" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, { name = "python-decouple", specifier = ">=3.8" }, { name = "whitenoise", specifier = ">=6.7" }, ] diff --git a/vergabe_teilnahme/apps/accounts/context_processors.py b/vergabe_teilnahme/apps/accounts/context_processors.py new file mode 100644 index 0000000..4dc9b55 --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/context_processors.py @@ -0,0 +1,7 @@ +from django.conf import settings + + +def company_context(request): + if settings.NETKINGDOM_ENABLED: + return {'company_name': settings.COMPANY_DISPLAY_NAME} + return {} diff --git a/vergabe_teilnahme/apps/accounts/middleware.py b/vergabe_teilnahme/apps/accounts/middleware.py index 8bbbddb..46f7ba8 100644 --- a/vergabe_teilnahme/apps/accounts/middleware.py +++ b/vergabe_teilnahme/apps/accounts/middleware.py @@ -1,3 +1,7 @@ +import time + +from django.conf import settings +from django.contrib.auth import logout from django.contrib.auth.middleware import LoginRequiredMiddleware from django.contrib.auth.views import redirect_to_login from django.http import HttpResponse @@ -8,15 +12,31 @@ from django.utils.cache import add_never_cache_headers class PilotLoginRequiredMiddleware(LoginRequiredMiddleware): """Use Django's default-deny gate, including full-page HTMX reauthentication.""" + def process_request(self, request): + expiry = request.session.get("oidc_expires") + if expiry is not None and ( + expiry <= time.time() + or request.user.is_staff + or request.user.is_superuser + or not settings.NETKINGDOM_ENABLED + or request.session.get("oidc_binding") + != [ + settings.NETKINGDOM_ISSUER, + settings.NETKINGDOM_TENANT, + settings.NETKINGDOM_CLIENT_ID, + ] + ): + logout(request) + def handle_no_permission(self, request, view_func): - if request.headers.get('HX-Request') == 'true': + if request.headers.get("HX-Request") == "true": # The original URL may render only a fragment or accept only POST. - login = redirect_to_login(reverse('home'), self.get_login_url(view_func)) - return HttpResponse(status=401, headers={'HX-Redirect': login.url}) + login = redirect_to_login(reverse("home"), self.get_login_url(view_func)) + return HttpResponse(status=401, headers={"HX-Redirect": login.url}) return super().handle_no_permission(request, view_func) def process_response(self, request, response): # Company records and account pages must not survive in shared caches. - if request.path_info != '/health/': + if request.path_info != "/health/": add_never_cache_headers(response) return response diff --git a/vergabe_teilnahme/apps/accounts/migrations/0003_oidcidentity.py b/vergabe_teilnahme/apps/accounts/migrations/0003_oidcidentity.py new file mode 100644 index 0000000..25b781f --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/migrations/0003_oidcidentity.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.5 on 2026-09-12 00:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0002_alter_mitarbeiter_mobilnummer_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="OIDCIdentity", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("issuer", models.CharField(max_length=512)), + ("subject", models.CharField(max_length=512)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("issuer", "subject"), name="unique_oidc_identity" + ) + ], + }, + ), + ] diff --git a/vergabe_teilnahme/apps/accounts/models.py b/vergabe_teilnahme/apps/accounts/models.py index f452959..dfc8444 100644 --- a/vergabe_teilnahme/apps/accounts/models.py +++ b/vergabe_teilnahme/apps/accounts/models.py @@ -23,3 +23,13 @@ class Mitarbeiter(AbstractUser): class Meta: verbose_name = 'Mitarbeiter' verbose_name_plural = 'Mitarbeiter' + + +class OIDCIdentity(models.Model): + issuer = models.CharField(max_length=512) + subject = models.CharField(max_length=512) + user = models.OneToOneField(Mitarbeiter, on_delete=models.PROTECT) + + class Meta: + constraints = [models.UniqueConstraint(fields=['issuer', 'subject'], + name='unique_oidc_identity')] diff --git a/vergabe_teilnahme/apps/accounts/oidc.py b/vergabe_teilnahme/apps/accounts/oidc.py new file mode 100644 index 0000000..79c70de --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/oidc.py @@ -0,0 +1,210 @@ +"""Bounded NetKingdom OIDC relying party for one isolated company instance.""" + +import base64 +import hashlib +import json +import secrets +import time +from functools import lru_cache +from urllib.parse import urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +import jwt +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + + +class LoginRejectedError(ValueError): + pass + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def configuration(): + issuer = settings.NETKINGDOM_ISSUER + callback = settings.NETKINGDOM_CALLBACK + for value in (issuer, callback): + parts = urlsplit(value) + if ( + parts.scheme != "https" + or not parts.hostname + or parts.username + or parts.password + or parts.query + or parts.fragment + or parts.hostname in {"localhost", "127.0.0.1"} + ): + raise ImproperlyConfigured("NetKingdom requires fixed HTTPS issuer and callback URLs") + if not settings.NETKINGDOM_CLIENT_ID or not settings.NETKINGDOM_TENANT: + raise ImproperlyConfigured("NetKingdom client and company binding are required") + expected = settings.APP_BASE_PATH + "/accounts/oidc/callback/" + if urlsplit(callback).path != expected: + raise ImproperlyConfigured("NetKingdom callback must match the fixed company path") + if settings.SESSION_ENGINE == "django.contrib.sessions.backends.signed_cookies": + raise ImproperlyConfigured("OIDC requires server-side sessions") + return issuer, settings.NETKINGDOM_CLIENT_ID, callback + + +def read_json(url, data=None): + request = Request( + url, + data=data, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + with build_opener(NoRedirect).open(request, timeout=10) as response: + body = response.read(262145) + if len(body) > 262144: + raise LoginRejectedError("Oversized provider response") + result = json.loads(body) + if not isinstance(result, dict): + raise LoginRejectedError("Invalid provider response") + return result + + +@lru_cache(maxsize=8) +def discovery(issuer, period): + metadata = read_json(issuer.rstrip("/") + "/.well-known/openid-configuration") + if metadata.get("issuer") != issuer: + raise LoginRejectedError("Issuer mismatch") + for field in ("authorization_endpoint", "token_endpoint", "jwks_uri"): + parts = urlsplit(metadata.get(field, "")) + if ( + parts.scheme != "https" + or parts.netloc != urlsplit(issuer).netloc + or parts.username + or parts.password + or parts.fragment + or parts.query + ): + raise LoginRejectedError("Unapproved provider endpoint") + if "S256" not in metadata.get( + "code_challenge_methods_supported", [] + ) or "RS256" not in metadata.get("id_token_signing_alg_values_supported", []): + raise LoginRejectedError("Provider does not support the admitted flow") + return metadata + + +@lru_cache(maxsize=8) +def key_client(uri): + return jwt.PyJWKClient(uri, timeout=10, lifespan=300) + + +def begin(): + issuer, client, callback = configuration() + metadata = discovery(issuer, int(time.time() // 300)) + pending = { + "state": secrets.token_urlsafe(32), + "nonce": secrets.token_urlsafe(32), + "verifier": secrets.token_urlsafe(64), + "created": time.time(), + } + challenge = ( + base64.urlsafe_b64encode( + hashlib.sha256(pending["verifier"].encode("ascii")).digest(), + ) + .decode("ascii") + .rstrip("=") + ) + parameters = { + "response_type": "code", + "client_id": client, + "redirect_uri": callback, + "scope": "openid profile groups", + "state": pending["state"], + "nonce": pending["nonce"], + "code_challenge": challenge, + "code_challenge_method": "S256", + "prompt": "login", + "tenant_hint": settings.NETKINGDOM_TENANT, + } + return pending, metadata["authorization_endpoint"] + "?" + urlencode(parameters) + + +def verify_claims(claims): + """Identity evidence plus the invited pilot's explicit company admission rule.""" + tenant = settings.NETKINGDOM_TENANT + groups, roles = claims.get("groups"), claims.get("roles") + if ( + claims.get("tenant") != tenant + or claims.get("tenant_source") != "directory" + or claims.get("principal_type") != "human" + or not isinstance(groups, list) + or not all(isinstance(g, str) for g in groups) + or not isinstance(roles, list) + or not all(isinstance(r, str) for r in roles) + or tenant + ":users" not in groups + or {"netkingdom-suspended", "net-kingdom-admins"} & set(groups) + or {"platform-operator", "platform-root", "emergency"} & set(roles) + ): + raise LoginRejectedError("Company membership is not established") + assurance = claims.get("assurance") + if not isinstance(assurance, dict) or assurance.get("level") not in {"aal1", "aal2", "aal3"}: + raise LoginRejectedError("Missing authentication assurance") + if not isinstance(claims.get("sub"), str) or not 0 < len(claims["sub"]) <= 512: + raise LoginRejectedError("Invalid subject") + + +def complete(pending, state, code): + if ( + not isinstance(pending, dict) + or not state + or not code + or len(code) > 8192 + or not secrets.compare_digest(pending.get("state", ""), state) + or not 0 <= time.time() - pending.get("created", 0) <= 600 + ): + raise LoginRejectedError("Invalid or expired sign-in") + issuer, client, callback = configuration() + metadata = discovery(issuer, int(time.time() // 300)) + tokens = read_json( + metadata["token_endpoint"], + urlencode( + { + "grant_type": "authorization_code", + "client_id": client, + "redirect_uri": callback, + "code": code, + "code_verifier": pending["verifier"], + } + ).encode("ascii"), + ) + token = tokens.get("id_token") + if not isinstance(token, str) or len(token) > 32768: + raise LoginRejectedError("Missing ID token") + key = key_client(metadata["jwks_uri"]).get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + key.key, + algorithms=["RS256"], + issuer=issuer, + audience=client, + options={"require": ["iss", "sub", "aud", "exp", "iat", "nonce"]}, + ) + audience = claims["aud"] + if ( + (isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != client) + or ("azp" in claims and claims["azp"] != client) + or not isinstance(claims["nonce"], str) + or not secrets.compare_digest(claims["nonce"], pending["nonce"]) + ): + raise LoginRejectedError("Token binding mismatch") + if any(type(claims[field]) is not int for field in ("iat", "exp")): + raise LoginRejectedError("Invalid token timestamps") + if claims["iat"] < pending["created"] - 60 or claims["exp"] <= claims["iat"]: + raise LoginRejectedError("Token predates this sign-in") + verify_claims(claims) + # Store neither bearer tokens nor a provider password in the product session. + return { + "issuer": issuer, + "tenant": settings.NETKINGDOM_TENANT, + "client": client, + "subject": claims["sub"], + "label": str(claims.get("preferred_username") or "Ihr Benutzerkonto")[:150], + "expires": min(int(claims["exp"]), int(time.time()) + 300), + } diff --git a/vergabe_teilnahme/apps/accounts/test_oidc.py b/vergabe_teilnahme/apps/accounts/test_oidc.py new file mode 100644 index 0000000..4b5ad04 --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/test_oidc.py @@ -0,0 +1,265 @@ +"""Exercise the actual signed-token and browser-session admission boundaries.""" + +import time +from types import SimpleNamespace +from urllib.parse import parse_qs, urlsplit + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from django.test import Client +from django.urls import get_script_prefix, set_script_prefix + +from . import oidc +from .models import Mitarbeiter, OIDCIdentity + +pytestmark = pytest.mark.django_db +ISSUER = "https://kc.example.test" +TENANT = "tenant:trial:demo-company" +CLIENT = "vergabe-demo-company" + + +@pytest.fixture +def configured(settings, monkeypatch): + settings.NETKINGDOM_ENABLED = True + settings.NETKINGDOM_ISSUER = ISSUER + settings.NETKINGDOM_CLIENT_ID = CLIENT + settings.NETKINGDOM_TENANT = TENANT + settings.NETKINGDOM_CALLBACK = "https://vergabe.example.test/accounts/oidc/callback/" + settings.COMPANY_DISPLAY_NAME = "Demo Company" + settings.APP_BASE_PATH = "" + metadata = { + "issuer": ISSUER, + "authorization_endpoint": ISSUER + "/authorize", + "token_endpoint": ISSUER + "/token", + "jwks_uri": ISSUER + "/jwks", + "code_challenge_methods_supported": ["S256"], + "id_token_signing_alg_values_supported": ["RS256"], + } + monkeypatch.setattr(oidc, "discovery", lambda *args: metadata) + return settings + + +@pytest.fixture +def signed_flow(configured, monkeypatch): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + monkeypatch.setattr( + oidc, + "key_client", + lambda uri: SimpleNamespace( + get_signing_key_from_jwt=lambda token: SimpleNamespace(key=key.public_key()), + ), + ) + + def exchange(client, changes=None, token_override=None): + pending = client.session["oidc_pending"] + claims = { + "iss": ISSUER, + "sub": "subject-1", + "aud": CLIENT, + "iat": int(time.time()), + "exp": int(time.time()) + 600, + "nonce": pending["nonce"], + "tenant": TENANT, + "tenant_source": "directory", + "principal_type": "human", + "groups": [TENANT + ":users"], + "roles": ["user"], + "assurance": {"level": "aal1"}, + "preferred_username": "Demo member", + } + claims.update(changes or {}) + token = token_override or jwt.encode( + claims, key, algorithm="RS256", headers={"kid": "test"} + ) + monkeypatch.setattr(oidc, "read_json", lambda *args: {"id_token": token}) + return client.get( + "/accounts/oidc/callback/", {"code": "single-code", "state": pending["state"]} + ) + + return exchange + + +def test_welcome_does_not_log_in_and_start_requires_csrf(configured): + client = Client(enforce_csrf_checks=True) + response = client.get("/accounts/login/?next=https://evil.test&token=untrusted") + assert b"Demo Company" in response.content + assert b"Mit NetKingdom anmelden" in response.content + assert "_auth_user_id" not in client.session + assert client.get("/accounts/oidc/start/").status_code == 405 + assert client.post("/accounts/oidc/start/").status_code == 403 + response = client.post( + "/accounts/oidc/start/", + { + "csrfmiddlewaretoken": client.cookies["csrftoken"].value, + }, + ) + query = parse_qs(urlsplit(response.url).query) + assert query["prompt"] == ["login"] + assert query["code_challenge_method"] == ["S256"] + assert query["tenant_hint"] == [TENANT] + assert len(query["nonce"][0]) >= 32 + assert "evil.test" not in response.url + + +def test_verified_login_needs_explicit_confirmation_and_stable_mapping(client, signed_flow): + assert client.post("/accounts/oidc/start/").status_code == 302 + assert signed_flow(client).url == "/accounts/oidc/confirm/" + assert not Mitarbeiter.objects.exists() + assert client.get("/ausschreibungen/").status_code == 302 + assert b"Demo member" in client.get("/accounts/oidc/confirm/").content + assert client.post("/accounts/oidc/confirm/").url == "/" + user = Mitarbeiter.objects.get() + assert not user.is_staff and not user.is_superuser and not user.has_usable_password() + assert OIDCIdentity.objects.get().subject == "subject-1" + dashboard = client.get("/ausschreibungen/") + assert dashboard.status_code == 200 + assert b"Demo Company" in dashboard.content + assert "access_token" not in repr(dict(client.session)) + assert client.post("/accounts/oidc/start/").status_code == 302 + assert "_auth_user_id" not in client.session + assert signed_flow(client, {"preferred_username": "New display name"}).status_code == 302 + assert client.post("/accounts/oidc/confirm/").status_code == 302 + assert Mitarbeiter.objects.count() == 1 + + +@pytest.mark.parametrize( + "changes", + [ + {"iss": "https://evil.test"}, + {"aud": "other-client"}, + {"nonce": "wrong"}, + {"exp": 1}, + {"iat": int(time.time()) + 3600}, + {"nbf": int(time.time()) + 3600}, + {"tenant": "tenant:platform"}, + {"tenant_source": "registration"}, + {"principal_type": "service"}, + {"groups": []}, + {"groups": [TENANT + ":users", "netkingdom-suspended"]}, + {"roles": ["platform-operator"]}, + {"groups": [TENANT + ":users", "net-kingdom-admins"]}, + {"assurance": {"level": "aal0"}}, + {"sub": ""}, + {"roles": "user"}, + {"aud": [CLIENT, "other"], "azp": "other"}, + ], +) +def test_invalid_tokens_never_create_users(client, signed_flow, changes): + client.post("/accounts/oidc/start/") + response = signed_flow(client, changes) + assert response.status_code == 403 + assert not Mitarbeiter.objects.exists() + assert "_auth_user_id" not in client.session + + +def test_wrong_algorithm_denied(client, signed_flow): + client.post("/accounts/oidc/start/") + assert ( + signed_flow( + client, token_override=jwt.encode({"sub": "forged"}, "x" * 32, algorithm="HS256") + ).status_code + == 403 + ) + + +def test_state_is_browser_bound_expired_and_one_use(client, signed_flow): + client.post("/accounts/oidc/start/") + original = client.session["oidc_pending"] + other = Client() + response = other.get("/accounts/oidc/callback/", {"state": original["state"], "code": "x"}) + assert response.status_code == 403 + assert signed_flow(client).status_code == 302 + assert ( + client.get( + "/accounts/oidc/callback/", {"state": original["state"], "code": "x"} + ).status_code + == 403 + ) + assert client.post("/accounts/oidc/confirm/").status_code == 403 + client.post("/accounts/oidc/start/") + session = client.session + session["oidc_pending"]["created"] -= 601 + session.save() + assert signed_flow(client).status_code == 403 + + +def test_confirmation_requires_csrf(client, signed_flow): + client.post("/accounts/oidc/start/") + signed_flow(client) + guarded = Client(enforce_csrf_checks=True) + guarded.cookies = client.cookies + assert guarded.post("/accounts/oidc/confirm/").status_code == 403 + assert not Mitarbeiter.objects.exists() + + +def test_no_email_merge_and_inactive_or_staff_accounts_stay_denied(client, signed_flow): + existing = Mitarbeiter.objects.create_user(username="Demo member", email="same@example.test") + client.post("/accounts/oidc/start/") + signed_flow(client, {"email": existing.email}) + client.post("/accounts/oidc/confirm/") + linked = OIDCIdentity.objects.get().user + assert linked.pk != existing.pk + linked.is_active = False + linked.save() + assert client.get("/ausschreibungen/").status_code == 302 + client.post("/accounts/oidc/start/") + signed_flow(client) + assert client.post("/accounts/oidc/confirm/").status_code == 403 + linked.is_active = True + linked.is_staff = True + linked.save() + client.post("/accounts/oidc/start/") + signed_flow(client) + assert client.post("/accounts/oidc/confirm/").status_code == 403 + + +def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client, signed_flow): + operator = Mitarbeiter.objects.create_superuser(username="operator", password="test-only") + client.force_login(operator) + client.post("/accounts/oidc/start/") + assert "_auth_user_id" not in client.session + assert signed_flow(client, {"tenant": "tenant:platform"}).status_code == 403 + client.post("/accounts/oidc/start/") + signed_flow(client) + client.post("/accounts/oidc/confirm/") + session = client.session + session["oidc_expires"] = time.time() - 1 + session.save() + assert client.get("/ausschreibungen/").status_code == 302 + + +def test_exact_company_callback_and_prefixed_links(configured, client): + old = get_script_prefix() + configured.APP_BASE_PATH = "/demo-company" + configured.FORCE_SCRIPT_NAME = "/demo-company" + configured.NETKINGDOM_CALLBACK = ( + "https://vergabe.example.test/demo-company/accounts/oidc/callback/" + ) + set_script_prefix("/demo-company") + try: + page = client.get("/accounts/login/") + assert b"/demo-company/accounts/oidc/start/" in page.content + target = client.post("/accounts/oidc/start/").url + assert parse_qs(urlsplit(target).query)["redirect_uri"] == [configured.NETKINGDOM_CALLBACK] + configured.NETKINGDOM_CALLBACK = ( + "https://vergabe.example.test/other/accounts/oidc/callback/" + ) + assert client.post("/accounts/oidc/start/").status_code == 503 + finally: + set_script_prefix(old) + + +def test_disabled_oidc_preserves_existing_pilot_login(settings, client): + settings.NETKINGDOM_ENABLED = False + assert b"Benutzername" in client.get("/accounts/login/").content + assert client.post("/accounts/oidc/start/").status_code == 404 + assert client.get("/accounts/oidc/callback/").status_code == 404 + + +def test_wrong_signature_denied(client, signed_flow): + client.post("/accounts/oidc/start/") + another = rsa.generate_private_key(public_exponent=65537, key_size=2048) + token = jwt.encode({"sub": "forged"}, another, algorithm="RS256") + assert signed_flow(client, token_override=token).status_code == 403 + assert not Mitarbeiter.objects.exists() diff --git a/vergabe_teilnahme/apps/accounts/urls.py b/vergabe_teilnahme/apps/accounts/urls.py index 946e92c..45672c9 100644 --- a/vergabe_teilnahme/apps/accounts/urls.py +++ b/vergabe_teilnahme/apps/accounts/urls.py @@ -1,21 +1,32 @@ from django.contrib.auth import views from django.urls import path, reverse_lazy -from .forms import AuthenticationForm, PasswordChangeForm +from . import views as company_views +from .forms import PasswordChangeForm -app_name = 'accounts' +app_name = "accounts" urlpatterns = [ - path('login/', views.LoginView.as_view( - template_name='accounts/login.html', authentication_form=AuthenticationForm, - ), name='login'), - path('logout/', views.LogoutView.as_view(), name='logout'), - path('password-change/', views.PasswordChangeView.as_view( - template_name='accounts/password_change.html', - form_class=PasswordChangeForm, - success_url=reverse_lazy('accounts:password_change_done'), - ), name='password_change'), - path('password-change/done/', views.PasswordChangeDoneView.as_view( - template_name='accounts/password_change_done.html', - ), name='password_change_done'), + path("login/", company_views.company_login, name="login"), + path("welcome/", company_views.welcome, name="welcome"), + path("oidc/start/", company_views.oidc_start, name="oidc_start"), + path("oidc/callback/", company_views.oidc_callback, name="oidc_callback"), + path("oidc/confirm/", company_views.oidc_confirm, name="oidc_confirm"), + path("logout/", views.LogoutView.as_view(), name="logout"), + path( + "password-change/", + views.PasswordChangeView.as_view( + template_name="accounts/password_change.html", + form_class=PasswordChangeForm, + success_url=reverse_lazy("accounts:password_change_done"), + ), + name="password_change", + ), + path( + "password-change/done/", + views.PasswordChangeDoneView.as_view( + template_name="accounts/password_change_done.html", + ), + name="password_change_done", + ), ] diff --git a/vergabe_teilnahme/apps/accounts/views.py b/vergabe_teilnahme/apps/accounts/views.py index 91ea44a..adac480 100644 --- a/vergabe_teilnahme/apps/accounts/views.py +++ b/vergabe_teilnahme/apps/accounts/views.py @@ -1,3 +1,137 @@ -from django.shortcuts import render +import hashlib +import time +from urllib.error import URLError -# Create your views here. +import jwt +from django.conf import settings +from django.contrib.auth import login, logout +from django.contrib.auth.decorators import login_not_required +from django.contrib.auth.views import LoginView +from django.core.exceptions import ImproperlyConfigured +from django.db import IntegrityError, transaction +from django.http import Http404 +from django.shortcuts import redirect, render +from django.views.decorators.debug import sensitive_variables +from django.views.decorators.http import require_GET, require_POST + +from . import oidc +from .forms import AuthenticationForm +from .models import Mitarbeiter, OIDCIdentity + + +def context(**extra): + return {"company_name": settings.COMPANY_DISPLAY_NAME, **extra} + + +@login_not_required +def company_login(request): + if settings.NETKINGDOM_ENABLED: + return welcome(request) + return LoginView.as_view( + template_name="accounts/login.html", authentication_form=AuthenticationForm + )(request) + + +@login_not_required +@require_GET +def welcome(request): + if not settings.NETKINGDOM_ENABLED: + return redirect("accounts:login") + # A setup return never establishes or changes an application session. + return render(request, "accounts/welcome.html", context()) + + +@login_not_required +@require_POST +@sensitive_variables() +def oidc_start(request): + if not settings.NETKINGDOM_ENABLED: + raise Http404 + # Explicit CSRF-protected action clears only this product's previous login. + logout(request) + try: + pending, target = oidc.begin() + except (ValueError, URLError, TimeoutError, OSError, ImproperlyConfigured): + return render(request, "accounts/welcome.html", context(login_error=True), status=503) + request.session["oidc_pending"] = pending + request.session.set_expiry(600) + return redirect(target) + + +@login_not_required +@require_GET +@sensitive_variables() +def oidc_callback(request): + if not settings.NETKINGDOM_ENABLED: + raise Http404 + pending = request.session.pop("oidc_pending", None) + request.session.pop("oidc_confirm", None) + try: + if request.GET.get("error") or len(request.GET.getlist("state")) != 1: + raise oidc.LoginRejectedError("Authorization was not completed") + verified = oidc.complete(pending, request.GET.get("state", ""), request.GET.get("code", "")) + except (ValueError, jwt.PyJWTError, URLError, TimeoutError, OSError, ImproperlyConfigured): + return render(request, "accounts/welcome.html", context(login_error=True), status=403) + request.session["oidc_confirm"] = verified + # Remove the authorization code from the address bar before displaying identity. + return redirect("accounts:oidc_confirm") + + +@login_not_required +@sensitive_variables() +def oidc_confirm(request): + if not settings.NETKINGDOM_ENABLED: + raise Http404 + verified = request.session.get("oidc_confirm") + if ( + not verified + or verified["expires"] <= time.time() + or verified.get("tenant") != settings.NETKINGDOM_TENANT + or verified.get("issuer") != settings.NETKINGDOM_ISSUER + or verified.get("client") != settings.NETKINGDOM_CLIENT_ID + ): + request.session.pop("oidc_confirm", None) + return render(request, "accounts/welcome.html", context(login_error=True), status=403) + if request.method == "GET": + return render(request, "accounts/confirm.html", context(identity_label=verified["label"])) + if request.method != "POST": + from django.http import HttpResponseNotAllowed + + return HttpResponseNotAllowed(["GET", "POST"]) + request.session.pop("oidc_confirm", None) + try: + with transaction.atomic(): + identity = ( + OIDCIdentity.objects.select_related("user") + .filter( + issuer=verified["issuer"], + subject=verified["subject"], + ) + .first() + ) + if identity is None: + # Never merge by email, display name or directory username. + username = ( + "nk_" + + hashlib.sha256( + (verified["issuer"] + "\0" + verified["subject"]).encode(), + ).hexdigest() + ) + user = Mitarbeiter(username=username, first_name=verified["label"][:150]) + user.set_unusable_password() + user.save() + identity = OIDCIdentity.objects.create( + issuer=verified["issuer"], + subject=verified["subject"], + user=user, + ) + user = identity.user + if not user.is_active or user.is_staff or user.is_superuser: + raise oidc.LoginRejectedError("Product account is not admitted") + except (IntegrityError, oidc.LoginRejectedError): + return render(request, "accounts/welcome.html", context(login_error=True), status=403) + login(request, user, backend="django.contrib.auth.backends.ModelBackend") + request.session["oidc_expires"] = verified["expires"] + request.session["oidc_binding"] = [verified["issuer"], verified["tenant"], verified["client"]] + request.session.set_expiry(max(1, int(verified["expires"] - time.time()))) + return redirect("home") diff --git a/vergabe_teilnahme/settings/base.py b/vergabe_teilnahme/settings/base.py index dc588b5..c2adee4 100644 --- a/vergabe_teilnahme/settings/base.py +++ b/vergabe_teilnahme/settings/base.py @@ -56,6 +56,7 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'vergabe_teilnahme.apps.core.context_processors.vergabe_context', + 'vergabe_teilnahme.apps.accounts.context_processors.company_context', ], }, }, @@ -120,3 +121,11 @@ ISSUE_FACADE_GITEA: dict | None = None # 'owner': 'org', # 'repo': 'vergabe', # } + +# Enabled only after the exact client, company and provider release are admitted. +NETKINGDOM_ENABLED = config('NETKINGDOM_ENABLED', default=False, cast=bool) +NETKINGDOM_ISSUER = config('NETKINGDOM_ISSUER', default='https://kc.coulomb.social') +NETKINGDOM_CLIENT_ID = config('NETKINGDOM_CLIENT_ID', default='') +NETKINGDOM_CALLBACK = config('NETKINGDOM_CALLBACK', default='') +NETKINGDOM_TENANT = config('NETKINGDOM_TENANT', default='') +COMPANY_DISPLAY_NAME = config('COMPANY_DISPLAY_NAME', default='Ihrem Unternehmen') diff --git a/vergabe_teilnahme/templates/accounts/confirm.html b/vergabe_teilnahme/templates/accounts/confirm.html new file mode 100644 index 0000000..12bf99a --- /dev/null +++ b/vergabe_teilnahme/templates/accounts/confirm.html @@ -0,0 +1,12 @@ +{% extends "accounts/base.html" %} +{% block title %}Anmeldung bestätigen{% endblock %} +{% block content %} +

          Anmelden bei {{ company_name }}

          +

          Sie fahren mit {{ identity_label }} fort.

          + + {% csrf_token %} + +
          + {% csrf_token %} +
          +{% endblock %} diff --git a/vergabe_teilnahme/templates/accounts/welcome.html b/vergabe_teilnahme/templates/accounts/welcome.html new file mode 100644 index 0000000..c39891c --- /dev/null +++ b/vergabe_teilnahme/templates/accounts/welcome.html @@ -0,0 +1,12 @@ +{% extends "accounts/base.html" %} +{% block title %}Willkommen bei {{ company_name }}{% endblock %} +{% block content %} +

          Willkommen bei {{ company_name }}

          +

          Hier bearbeiten Sie die Ausschreibungen Ihres Unternehmens gemeinsam.

          +{% if login_error %}{% endif %} +
          + {% csrf_token %} + +
          +

          Verwenden Sie Ihr eigenes Benutzerkonto und das von Ihnen eingerichtete Passwort. Wenn Sie den Zugang für eine andere Person vorbereitet haben, geben Sie ihr den Unternehmenslink weiter.

          +{% endblock %} diff --git a/vergabe_teilnahme/templates/partials/topbar.html b/vergabe_teilnahme/templates/partials/topbar.html index e16b4c3..f514f8f 100644 --- a/vergabe_teilnahme/templates/partials/topbar.html +++ b/vergabe_teilnahme/templates/partials/topbar.html @@ -7,7 +7,7 @@ - Vergabe Teilnahme + Vergabe Teilnahme{% if company_name %}{{ company_name }}{% endif %}
          @@ -39,8 +39,10 @@
          {{ request.user.get_rolle_display|default:"Mitarbeiter" }}
          + {% if request.user.has_usable_password %} Passwort ändern + {% endif %} {% csrf_token %} diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index 1e107c7..d562917 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -100,6 +100,41 @@ vergabe-teilnahme namespace nor any Deployment whose name/image contains vergabe/teilnahme. Treat the old runbook as historical intent, not a live return. Native placement and existing data inventory remain required before cutover. +2026-09-12 deployment evidence: the operator added A records for +vergabe-teilnahme.coulomb.social and users.coulomb.social. Authoritative IONOS +and recursive readback both return 92.205.62.239. Both cert-manager certificates +are Ready. The portal now uses https://users.coulomb.social/login; its legacy +nip.io address redirects to the canonical hostname. The exact new callback is +registered alongside the rollback callback; scopes, public client type and PKCE +remain unchanged. Canonical authorization succeeds; unapproved callback and +missing PKCE fail. This supersedes earlier DNS and portal-hostname blockers. + +Helm release vergabe-teilnahme revision 1 is deployed in vergabe-demo-company, +chart 0.2.1, pinned product digest cebe8ca6218cc89f903e8a69e06ac50d3812526d4119cf4da485cc173f674b68. +Deployment UID 2152014d-a020-4c5e-a3b0-9575e9f21c44 is Ready 1/1. Its init +migration completed before the web process; both phases share the same 60m CPU / +256Mi memory request. Node requests now total 3965m of 4000m; the 35m remainder +matches the accepted prototype allocation. No unrelated resource requests changed. + +RPF-WP-0039 delivered fresh vergabe_demo_company database/role on apps-pg and +runtime Secret vergabe-demo-company/vergabe-demo-env. The app's own connection +confirms that exact database/role. The role is non-superuser, cannot create roles +or databases, has a 20-connection ceiling and 15-second timeouts, and cannot +CONNECT to historical vergabe_db, coulomb_social_db or apps_meta. Both dedicated +PVCs are Bound: 5Gi media and 1Gi issue state. Historical data was not selected +or overwritten; no credentials are recorded here. + +Thirteen live Chromium/HTTP checks pass: page and assets, secure tenant-scoped +CSRF cookie, anonymous login gate and media refusal, private operational path +refusal, neighboring/root path refusal, canonical slash, HTTPS redirect and +missing-CSRF POST denial. Migration/app initialization also proves consumer +connectivity. The empty product has zero accounts, including zero staff accounts. +The current login is still the interim Django login, not NetKingdom SSO. Native +recipient login, company welcome and account mapping remain VERGABE-WP-0019-T06. +RAPPS-WP-0014-T03 retains restart and coherent off-host backup/isolated restore; +the latest existing apps-pg base backup predates this new database. No pilot-user +acceptance, shared tenancy, MFA completion or natural factory-worker trace is claimed. + ## Accept onboarding, collaboration, recovery and support with pilot users ```task @@ -108,7 +143,7 @@ status: wait priority: high assignee: the-custodian depends_on: [VERGABE-WP-0019-T03] -blocking_reason: "Native tenant/user/password setup is verified. Await admitted application placement, tenant welcome/sign-in connection and demonstrated restore." +blocking_reason: "Application placement and native tenant/user/password setup are verified. Await tenant welcome/SSO connection, two-user acceptance and demonstrated restore." state_hub_task_id: "db807f96-f5b8-528d-bc33-75a33fbbf1e4" ``` @@ -166,7 +201,7 @@ preceding service-login failure. It does not create a Django account or session. ```task id: VERGABE-WP-0019-T06 -status: todo +status: progress priority: high assignee: the-custodian state_hub_task_id: "455b33f9-163a-5389-86a3-04000c32d176" @@ -199,3 +234,16 @@ invalid/expired setup returns, unapproved external return URLs, wrong-tenant identities and operator-session confusion fail safely. Keep setup tokens, credentials and personal addresses out of work records. The earlier manual Django login gate remains an interim product capability, not SSO proof. + + +2026-09-12 continuation: implemented company welcome, OIDC code+PKCE/nonce +verification, explicit account confirmation and stable issuer/subject mapping. +New accounts are ordinary users with unusable local passwords; signed directory +membership, exact tenant and bounded session expiry are required. Added callback, +wrong-tenant/platform, signature, replay, CSRF, local deactivation and prefix tests. +Source/deployment contract: docs/netkingdom-sign-in.md. The current live issuer +predates tenant_source and drops prompt=login before Authelia, so enabling the +product depends on KEY-WP-0033 and NK-WP-0037's exact registration/provider rollout. +The password-setup company return is grant-bound in NK-WP-0037. Native recipient +login/MFA, product publication and live acceptance remain open; no completed SSO +or pilot acceptance is claimed from source tests. From cf50cc55338e298de56f2a6e1929848a4bfbc0ae Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Sat, 12 Sep 2026 02:45:18 +0200 Subject: [PATCH 16/24] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-09-12: - update .custodian-brief.md for vergabe-teilnahme Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- .custodian-brief.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 119fa96..63d17cc 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — vergabe-teilnahme **Domain:** communication -**Last synced:** 2026-09-11 23:01 UTC +**Last synced:** 2026-09-12 00:45 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -12,9 +12,9 @@ Progress: 3/6 done | workplan_id: `85b5f304-d497-5570-bebf-3a3669ef6a7d` **Open tasks:** - ! Accept onboarding, collaboration, recovery and support with pilot users `db807f96` - *(wait: Native demo-company exists and hostname/path is selected. Await identity-provisioner service-login repair, admitted application deployment and demonstrated restore.)* + *(wait: Native tenant/user/password setup is verified. Await admitted application placement, tenant welcome/sign-in connection and demonstrated restore.)* - ► Publish and prove the isolated deployment and recovery contract `9065d5cb` -- · Connect identity setup to the company's welcome and sign-in page `455b33f9` +- ► Connect identity setup to the company's welcome and sign-in page `455b33f9` ### Establish verified delivery for the primary customer tender product Progress: 2/3 done | workplan_id: `27a95f7b-cec4-50ca-8383-c1c95d996217` From 7e5cfba3801be34818f84fc05eb7a0a69dee8caf Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 02:53:55 +0200 Subject: [PATCH 17/24] Record tested sign-in release and attended rollout handoff Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- workplans/VERGABE-WP-0019-invited-company-pilot.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index d562917..e7fa856 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -247,3 +247,15 @@ product depends on KEY-WP-0033 and NK-WP-0037's exact registration/provider roll The password-setup company return is grant-bound in NK-WP-0037. Native recipient login/MFA, product publication and live acceptance remain open; no completed SSO or pilot acceptance is claimed from source tests. + + +2026-09-12 release readiness: source 8be2810 is published as +sha256:2cb393608a82be2851adfc27f2bf4d8ef5d709f1b0038be5d0999e38c68b039e +(Forgejo run 51). All 125 local app tests, build and migration checks pass. +Acceptance run 49 completed tests but failed at image export; the independent +publication gate passed, and acceptance run 52 plus smoke run 53 pass on +cf50cc5 with identical application source (only generated brief changed). +The exact release packet, rollback and server-dry-run manifests are in +railiance-apps/docs/vergabe-demo-company-sso-rollout.md. Runtime remains on the +interim gate pending the documented attended KeyCape upgrade and native user/MFA +acceptance. KEY-WP-0033 and NK-WP-0037 remain live receiving records. From c1727ed14d996331a40b500f7a4b3bdb40540b34 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 03:12:45 +0200 Subject: [PATCH 18/24] Record live company sign-in deployment Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- docs/netkingdom-sign-in.md | 5 +++-- .../VERGABE-WP-0019-invited-company-pilot.md | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/netkingdom-sign-in.md b/docs/netkingdom-sign-in.md index a0f4250..1e1b6ad 100644 --- a/docs/netkingdom-sign-in.md +++ b/docs/netkingdom-sign-in.md @@ -26,8 +26,9 @@ Passwords and bearer tokens are not persisted in the product. ## Exact demo binding Enable only with the registered client and the verified KeyCape fresh-login -release. The currently deployed `dcebd46` issuer lacks `tenant_source` and does -not propagate fresh-login requests to Authelia; this product fails closed on it. +release. The initial `dcebd46` issuer lacked `tenant_source` and fresh-login propagation. +The approved 2026-09-12 rollout replaced it with source 8d4336e; live forwarding +and the company entry are verified. Native recipient/MFA acceptance remains open. ``` NETKINGDOM_ENABLED=true diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index e7fa856..7c31c01 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -259,3 +259,18 @@ The exact release packet, rollback and server-dry-run manifests are in railiance-apps/docs/vergabe-demo-company-sso-rollout.md. Runtime remains on the interim gate pending the documented attended KeyCape upgrade and native user/MFA acceptance. KEY-WP-0033 and NK-WP-0037 remain live receiving records. + +2026-09-12 attended rollout executed after explicit operator approval. KeyCape +and password setup are Ready on the prepared digests; exact public client +registration was CAS-applied (config resourceVersion 60123977) with unrelated +config bytes/Secret data preserved. Existing portal and product client both +pass fresh-login forwarding, wrong-callback and missing-PKCE checks (6 checks). +Vergabe Helm revision 2 is Ready; identity migration completed, both PVCs remain, +and requests remain 60m CPU/256Mi memory. Eleven live product checks pass: +company welcome, anonymous gate, no-store, secure scoped CSRF, POST/CSRF-only +login start, native issuer redirect, private company/media protection and +invalid callback/confirmation rejection. Initial readback showed zero accounts, +identity mappings and staff accounts. Native invited-user sign-in/MFA and +confirmation are now requested from the operator; no user credential was used +by the agent. Recovery and two-user acceptance remain their existing tasks. +Evidence: railiance-apps/docs/evidence/2026-09-12-demo-company-sso-live.md. From c067993037f1cdeeb7b22d5600b51c649bc239db Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 10:34:44 +0200 Subject: [PATCH 19/24] Route rejected company sign-in to central account recovery Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- docs/netkingdom-sign-in.md | 9 ++++++ vergabe_teilnahme/apps/accounts/test_oidc.py | 32 +++++++++++++------ vergabe_teilnahme/apps/accounts/views.py | 13 ++++++-- vergabe_teilnahme/settings/base.py | 2 ++ .../VERGABE-WP-0019-invited-company-pilot.md | 11 +++++++ 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/docs/netkingdom-sign-in.md b/docs/netkingdom-sign-in.md index 1e1b6ad..c7e0dbf 100644 --- a/docs/netkingdom-sign-in.md +++ b/docs/netkingdom-sign-in.md @@ -73,3 +73,12 @@ fresh sign-in and any provider-required MFA, confirm their account, and enter the company workflow. Do not substitute an operator session. Recovery and two-user workflow acceptance remain the existing RAPPS-WP-0014-T03 and VERGABE-WP-0019-T04 tasks. + +## Rejected-login recovery + +Rejected callbacks and unusable confirmations clear pending identity state and +redirect to `NETKINGDOM_ACCOUNT_PORTAL_URL` + `/access-recovery` (default canonical +users.coulomb.social). The response is no-store and no-referrer; callback codes, +state and unverified identity are never forwarded. The central portal offers +verified account details and confirmed shared sign-out. Admission, CSRF, tenant, +principal and MFA rules remain enforced before any product account is created. diff --git a/vergabe_teilnahme/apps/accounts/test_oidc.py b/vergabe_teilnahme/apps/accounts/test_oidc.py index 4b5ad04..08eba6a 100644 --- a/vergabe_teilnahme/apps/accounts/test_oidc.py +++ b/vergabe_teilnahme/apps/accounts/test_oidc.py @@ -148,7 +148,10 @@ def test_verified_login_needs_explicit_confirmation_and_stable_mapping(client, s def test_invalid_tokens_never_create_users(client, signed_flow, changes): client.post("/accounts/oidc/start/") response = signed_flow(client, changes) - assert response.status_code == 403 + assert response.status_code == 302 + assert response.url == "https://users.coulomb.social/access-recovery" + assert response["Referrer-Policy"] == "no-referrer" + assert "oidc_confirm" not in client.session assert not Mitarbeiter.objects.exists() assert "_auth_user_id" not in client.session @@ -159,7 +162,7 @@ def test_wrong_algorithm_denied(client, signed_flow): signed_flow( client, token_override=jwt.encode({"sub": "forged"}, "x" * 32, algorithm="HS256") ).status_code - == 403 + == 302 ) @@ -168,20 +171,20 @@ def test_state_is_browser_bound_expired_and_one_use(client, signed_flow): original = client.session["oidc_pending"] other = Client() response = other.get("/accounts/oidc/callback/", {"state": original["state"], "code": "x"}) - assert response.status_code == 403 + assert response.status_code == 302 assert signed_flow(client).status_code == 302 assert ( client.get( "/accounts/oidc/callback/", {"state": original["state"], "code": "x"} ).status_code - == 403 + == 302 ) - assert client.post("/accounts/oidc/confirm/").status_code == 403 + assert client.post("/accounts/oidc/confirm/").status_code == 302 client.post("/accounts/oidc/start/") session = client.session session["oidc_pending"]["created"] -= 601 session.save() - assert signed_flow(client).status_code == 403 + assert signed_flow(client).status_code == 302 def test_confirmation_requires_csrf(client, signed_flow): @@ -205,13 +208,13 @@ def test_no_email_merge_and_inactive_or_staff_accounts_stay_denied(client, signe assert client.get("/ausschreibungen/").status_code == 302 client.post("/accounts/oidc/start/") signed_flow(client) - assert client.post("/accounts/oidc/confirm/").status_code == 403 + assert client.post("/accounts/oidc/confirm/").status_code == 302 linked.is_active = True linked.is_staff = True linked.save() client.post("/accounts/oidc/start/") signed_flow(client) - assert client.post("/accounts/oidc/confirm/").status_code == 403 + assert client.post("/accounts/oidc/confirm/").status_code == 302 def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client, signed_flow): @@ -219,7 +222,7 @@ def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client client.force_login(operator) client.post("/accounts/oidc/start/") assert "_auth_user_id" not in client.session - assert signed_flow(client, {"tenant": "tenant:platform"}).status_code == 403 + assert signed_flow(client, {"tenant": "tenant:platform"}).status_code == 302 client.post("/accounts/oidc/start/") signed_flow(client) client.post("/accounts/oidc/confirm/") @@ -261,5 +264,14 @@ def test_wrong_signature_denied(client, signed_flow): client.post("/accounts/oidc/start/") another = rsa.generate_private_key(public_exponent=65537, key_size=2048) token = jwt.encode({"sub": "forged"}, another, algorithm="RS256") - assert signed_flow(client, token_override=token).status_code == 403 + assert signed_flow(client, token_override=token).status_code == 302 assert not Mitarbeiter.objects.exists() + + +def test_rejected_callback_uses_fixed_recovery_without_browser_credentials(client, configured): + response = client.get("/accounts/oidc/callback/", {"code":"private-code", "state":"private-state", "next":"https://evil.example"}) + assert response.url == "https://users.coulomb.social/access-recovery" + assert "no-store" in response["Cache-Control"] + assert response["Referrer-Policy"] == "no-referrer" + assert not Mitarbeiter.objects.exists() + assert "_auth_user_id" not in client.session diff --git a/vergabe_teilnahme/apps/accounts/views.py b/vergabe_teilnahme/apps/accounts/views.py index adac480..a6f0e67 100644 --- a/vergabe_teilnahme/apps/accounts/views.py +++ b/vergabe_teilnahme/apps/accounts/views.py @@ -19,6 +19,13 @@ from .forms import AuthenticationForm from .models import Mitarbeiter, OIDCIdentity +def account_recovery(): + response = redirect(settings.NETKINGDOM_ACCOUNT_PORTAL_URL.rstrip("/") + "/access-recovery") + response["Cache-Control"] = "no-store" + response["Referrer-Policy"] = "no-referrer" + return response + + def context(**extra): return {"company_name": settings.COMPANY_DISPLAY_NAME, **extra} @@ -71,7 +78,7 @@ def oidc_callback(request): raise oidc.LoginRejectedError("Authorization was not completed") verified = oidc.complete(pending, request.GET.get("state", ""), request.GET.get("code", "")) except (ValueError, jwt.PyJWTError, URLError, TimeoutError, OSError, ImproperlyConfigured): - return render(request, "accounts/welcome.html", context(login_error=True), status=403) + return account_recovery() request.session["oidc_confirm"] = verified # Remove the authorization code from the address bar before displaying identity. return redirect("accounts:oidc_confirm") @@ -91,7 +98,7 @@ def oidc_confirm(request): or verified.get("client") != settings.NETKINGDOM_CLIENT_ID ): request.session.pop("oidc_confirm", None) - return render(request, "accounts/welcome.html", context(login_error=True), status=403) + return account_recovery() if request.method == "GET": return render(request, "accounts/confirm.html", context(identity_label=verified["label"])) if request.method != "POST": @@ -129,7 +136,7 @@ def oidc_confirm(request): if not user.is_active or user.is_staff or user.is_superuser: raise oidc.LoginRejectedError("Product account is not admitted") except (IntegrityError, oidc.LoginRejectedError): - return render(request, "accounts/welcome.html", context(login_error=True), status=403) + return account_recovery() login(request, user, backend="django.contrib.auth.backends.ModelBackend") request.session["oidc_expires"] = verified["expires"] request.session["oidc_binding"] = [verified["issuer"], verified["tenant"], verified["client"]] diff --git a/vergabe_teilnahme/settings/base.py b/vergabe_teilnahme/settings/base.py index c2adee4..bd7183f 100644 --- a/vergabe_teilnahme/settings/base.py +++ b/vergabe_teilnahme/settings/base.py @@ -129,3 +129,5 @@ NETKINGDOM_CLIENT_ID = config('NETKINGDOM_CLIENT_ID', default='') NETKINGDOM_CALLBACK = config('NETKINGDOM_CALLBACK', default='') NETKINGDOM_TENANT = config('NETKINGDOM_TENANT', default='') COMPANY_DISPLAY_NAME = config('COMPANY_DISPLAY_NAME', default='Ihrem Unternehmen') + +NETKINGDOM_ACCOUNT_PORTAL_URL = config('NETKINGDOM_ACCOUNT_PORTAL_URL', default='https://users.coulomb.social') diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index 7c31c01..92225db 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -274,3 +274,14 @@ identity mappings and staff accounts. Native invited-user sign-in/MFA and confirmation are now requested from the operator; no user credential was used by the agent. Recovery and two-user acceptance remain their existing tasks. Evidence: railiance-apps/docs/evidence/2026-09-12-demo-company-sso-live.md. + +### Rejected-login recovery follow-up — 2026-09-12 + +Operator reports a dead-end error after trying a non-customer identity. Recent +issuer evidence shows token exchange failure; the exact browser cause is not +yet confirmed. Rejected callbacks and unusable confirmation now redirect to the +central account recovery page without code, state or claimed identity. Existing +tenant, principal, signature, CSRF and account-admission boundaries remain. +Validation: 126 application tests passed using an isolated in-memory database. +KEY-WP-0034 owns provider recovery/sign-out; USER-WP-0026 owns account visibility. +Publication and attended live recovery verification are in progress. From e0aa5cff6ba3ace2924982506860409213ad3c45 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 10:50:35 +0200 Subject: [PATCH 20/24] Record live rejected-login recovery verification Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- workplans/VERGABE-WP-0019-invited-company-pilot.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index 92225db..a544261 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -285,3 +285,12 @@ tenant, principal, signature, CSRF and account-admission boundaries remain. Validation: 126 application tests passed using an isolated in-memory database. KEY-WP-0034 owns provider recovery/sign-out; USER-WP-0026 owns account visibility. Publication and attended live recovery verification are in progress. + +### Live recovery rollout — 2026-09-12 + +Recovery is deployed in KeyCape 4d8b8fe, User Engine e54b6ee and Vergabe c067993 +(Helm revision 3). All three are Ready. Six provider checks, eleven product +checks and six fresh anonymous Chromium checks pass, including actual provider +logout POST and return to the portal without test overrides. Real-user identity +switching is still awaiting operator evidence; no authenticated/MFA acceptance +is inferred. Detailed receipt: railiance-apps/docs/evidence/2026-09-12-account-recovery-live.md. From 2c9a77e1606c5148ec4b1305a1454a2d5cbbb830 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 14 Sep 2026 15:50:50 +0200 Subject: [PATCH 21/24] Classify open workplans with flavor (CUST-WP-0072). Set flavor on open workplans from origin/prose/status. Copy existing depends_on aliases only. Do not promote residuals. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267 --- workplans/VERGABE-WP-0018-customer-factory-delivery.md | 1 + workplans/VERGABE-WP-0019-invited-company-pilot.md | 1 + 2 files changed, 2 insertions(+) diff --git a/workplans/VERGABE-WP-0018-customer-factory-delivery.md b/workplans/VERGABE-WP-0018-customer-factory-delivery.md index 58dfbeb..b97f88a 100644 --- a/workplans/VERGABE-WP-0018-customer-factory-delivery.md +++ b/workplans/VERGABE-WP-0018-customer-factory-delivery.md @@ -5,6 +5,7 @@ title: "Establish verified delivery for the primary customer tender product" domain: communication repo: vergabe-teilnahme status: blocked +flavor: implementation owner: the-custodian topic_slug: vergabe-teilnahme created: "2026-09-08" diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index a544261..6ccfad8 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -5,6 +5,7 @@ title: "Admit the first invited company pilot with protected access and recovera domain: communication repo: vergabe-teilnahme status: active +flavor: implementation owner: the-custodian topic_slug: vergabe-teilnahme created: "2026-09-11" From dd88ff632e05a48347cadec1534e0fdbd5284952 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 25 Sep 2026 01:49:43 +0200 Subject: [PATCH 22/24] Record the completed Vergabe sign-in on the freshness image. Assistant: grok Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f --- workplans/VERGABE-WP-0019-invited-company-pilot.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index 6ccfad8..e0ce32f 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -9,7 +9,7 @@ flavor: implementation owner: the-custodian topic_slug: vergabe-teilnahme created: "2026-09-11" -updated: "2026-09-12" +updated: "2026-09-25" related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001, CUST-WP-0071] state_hub_workstream_id: "85b5f304-d497-5570-bebf-3a3669ef6a7d" --- @@ -287,6 +287,13 @@ Validation: 126 application tests passed using an isolated in-memory database. KEY-WP-0034 owns provider recovery/sign-out; USER-WP-0026 owns account visibility. Publication and attended live recovery verification are in progress. +2026-09-24 23:46 UTC the founder completed one Vergabe sign-in on the freshness +image. The issuer issued a token for `vergabe-demo-company` after one failed +code exchange. That closes the native-login wait owned by KEY-WP-0033. This +task still owns the rest of the pilot: a second user, the setup-to-welcome +handoff, and entry into the company workflow. Receipt: +`key-cape/docs/evidence/2026-09-24-fresh-login-and-account-switch.md`. + ### Live recovery rollout — 2026-09-12 Recovery is deployed in KeyCape 4d8b8fe, User Engine e54b6ee and Vergabe c067993 From d57071bba6785f01fd67229112f6565f188b4af7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 25 Sep 2026 01:52:27 +0200 Subject: [PATCH 23/24] Index the Vergabe sign-in task as in progress. Assistant: grok Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f --- WORK-RECORDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 753ce1a..670a241 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -35,7 +35,7 @@ | task | VERGABE-WP-0019-T03 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T04 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T05 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | -| task | VERGABE-WP-0019-T06 | todo | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T06 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VT-WP-0001-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T05 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T07 | done | — | workplans/VT-WP-0001-projektgeruest.md | From 5cdcbd2186525406ca699ec9335fd5c2e77c2eaf Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 25 Sep 2026 02:06:02 +0200 Subject: [PATCH 24/24] Record that the company database restore rehearsal passed. Assistant: grok Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f --- workplans/VERGABE-WP-0019-invited-company-pilot.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/workplans/VERGABE-WP-0019-invited-company-pilot.md b/workplans/VERGABE-WP-0019-invited-company-pilot.md index e0ce32f..ea7f756 100644 --- a/workplans/VERGABE-WP-0019-invited-company-pilot.md +++ b/workplans/VERGABE-WP-0019-invited-company-pilot.md @@ -132,9 +132,14 @@ missing-CSRF POST denial. Migration/app initialization also proves consumer connectivity. The empty product has zero accounts, including zero staff accounts. The current login is still the interim Django login, not NetKingdom SSO. Native recipient login, company welcome and account mapping remain VERGABE-WP-0019-T06. -RAPPS-WP-0014-T03 retains restart and coherent off-host backup/isolated restore; -the latest existing apps-pg base backup predates this new database. No pilot-user -acceptance, shared tenancy, MFA completion or natural factory-worker trace is claimed. + +2026-09-25: RAPPS-WP-0014-T03 restored `vergabe_demo_company` into a scratch +database in 2 seconds, 39 tables and 212 rows, checksum matched, scratch +dropped. The app pod was recreated and health returned 200. The backup artifact +is a workstation age file, not the Nextcloud upload. Media and issue-facade +claims are empty local-path volumes. Evidence: +`railiance-apps/docs/evidence/2026-09-25-vergabe-demo-company-restore.md`. +No second-user acceptance is claimed. ## Accept onboarding, collaboration, recovery and support with pilot users @@ -144,7 +149,7 @@ status: wait priority: high assignee: the-custodian depends_on: [VERGABE-WP-0019-T03] -blocking_reason: "Application placement and native tenant/user/password setup are verified. Await tenant welcome/SSO connection, two-user acceptance and demonstrated restore." +blocking_reason: "Database restore and app restart are proved. Await two ordinary users collaborating on a tender, task, and document." state_hub_task_id: "db807f96-f5b8-528d-bc33-75a33fbbf1e4" ```