Release govoplan-access v0.1.25: harden authentication and repair identity mappings
Module Package Release / publish-packages (push) Successful in 15s

This commit is contained in:
2026-09-08 01:32:20 +02:00
parent 0f8a05f8b9
commit 8f8072b4ae
25 changed files with 742 additions and 38 deletions
+64
View File
@@ -0,0 +1,64 @@
# Authentication cache boundary hardening
The authentication cache is an optimization, never an additional authentication
method or permission source. The September 2026 review found and reproduced
three violations of that boundary in isolated SQLite tests.
| Finding | Consequence | Resolution |
| --- | --- | --- |
| Service-account credentials passed through ordinary principal-summary refresh | A narrowed service-account ceiling could be replaced by backing membership roles; the service-account identifier and authentication method were lost. | Dedicated service-account resolution retains its provenance and checks the current ceiling/lifecycle on every request. It does not enter the interactive principal-summary cache. |
| A warmed API-key summary accepted the same secret through a session cookie | The warm path accepted a cookie-authenticated mutation without the CSRF rule applied to real browser sessions; the cold path rejected the credential. | API keys require an explicit Bearer or X-API-Key header on both paths. Session cookies still require matching CSRF cookie/header/hash for mutations. |
| Tenant-key intersection excluded only the historical `system:` spelling | Module-native system permissions and retained module wildcards could survive the tenant-only intersection. | Resolve wildcard grants to concrete registered tenant permissions and exclude system permissions by catalogue and compatibility aliases. |
No existing secrets, sessions, assignments, or database schema are changed.
Normal header-authenticated API keys and concrete tenant aliases remain
compatible. Clients relying on API keys in browser cookies, implicit unknown
wildcards, or accidental instance-level rights must correct their authentication
method or permission configuration; these are not preserved as compatibility
exceptions. Stored grants are not rewritten. Service-account access continues
to narrow immediately when its ceiling is reduced or its lifecycle blocks use.
Regression coverage is in `tests/test_auth_cache_security.py` and
`tests/test_permission_catalog_contract.py`; the tests exercise the full
credential resolver with principal caching enabled, not only the lower-level
API-key lookup. They also verify that valid session CSRF and concrete legacy
tenant aliases still work.
## Remaining coordinated password-change workflow
`Account.password_reset_required` is currently advisory metadata, not an
enforced sign-in restriction. The administrator UI states this limitation,
but the authentication-fields documentation previously claimed mandatory
replacement; its English and German text now reflects the implementation.
A generated password is disclosed once but is not a single-use login secret.
A follow-up must deliver the password-change endpoint, current-password
verification and replacement policy, CSRF and attempt limits, session
revocation/rotation and cache invalidation, a restricted reset-required
principal, and the corresponding accessible UI/recovery path together.
Enabling only a rejection gate would lock affected accounts out without any
supported way to finish the change. This review does not enable such a gate or
change existing passwords.
## Betriebshinweise
API-Schlüssel werden ausschließlich über `Authorization: Bearer` oder
`X-API-Key` gesendet, nicht über das Sitzungscookie. Ändernde Cookie-Anfragen
benötigen weiterhin einen passenden CSRF-Header samt Cookie und serverseitigem
Prüfwert. Dienstkonten behalten ihre eigene Herkunft und werden bei jeder
Anfrage gegen den aktuellen Berechtigungsrahmen und Lebenszyklus geprüft.
Mitgliedschaftsrollen oder zwischengespeicherte interaktive Rechte dürfen diesen
Rahmen nicht ersetzen.
Mandantenschlüssel erhalten keine instanzweiten Rechte, auch nicht unter
modulbezogenen Berechtigungsnamen. Platzhalter werden in konkrete registrierte
Mandantenrechte aufgelöst. Bestehende konkrete Mandantenrechte und ihre
Kompatibilitätsnamen bleiben erhalten; gespeicherte Geheimnisse und
Rollenzuweisungen werden nicht geändert.
Das Kennzeichen `password_reset_required` erzwingt derzeit keinen
Passwortwechsel. Ein einmal angezeigtes Anfangspasswort bleibt zur Anmeldung
verwendbar. Die Nachfolgeumsetzung muss Passwortänderung, eng begrenzten
Zwischenzugriff, Sitzungswechsel beziehungsweise Widerruf und eine bedienbare
Wiederherstellung gemeinsam liefern; eine alleinige Zugriffssperre würde
betroffene Konten ohne durchführbaren Passwortwechsel aussperren.
@@ -0,0 +1,79 @@
# External function mapping schema repair
Access owns `access_external_function_role_assignments`. Some older databases
record the Access baseline (`4a5b6c7d8e9f`) without this table. The mapping list
and `/api/v1/admin/external-function-role-mappings/delta` then fail with an
undefined-table error. This is a schema/history mismatch, not a reason to change
user permissions or recreate tenant data.
Forward repair revision `d8f1b4e7a0c3` follows Access `c7e0a3d6f9b2` on the
release track and `b6d9f2a5c8e1` on the disposable-development track. The latter
also requires Core's existing scope-table rename `4f2a9c8e7b6d`; this is a Core
contract and does not require the optional Tenancy or Organizations modules.
Before applying deployment migrations, back up and verify the database backup.
Use the configured migration track and ordinary deployment migration workflow,
including its deployment-wide advisory lock. Inspect the pending revision plan
before any targeted repair. Never replay or stamp the baseline, initialize dev
data, reset the database, or switch migration tracks to bypass the error.
The development launcher can run pending migrations when its file watcher
reloads the backend. Prepare and test a migration outside the watched source
tree, and complete the backup/preflight before placing a new migration file in
that tree. Do not assume that waiting to invoke a migration command prevents a
running development instance from applying it automatically.
The repair:
- Creates the absent mapping table only, with its baseline columns, role/scope
cascade foreign keys, primary key, tenant/source/function/role uniqueness,
and four lookup indexes.
- Does nothing if the table already exists. It does not alter partial tables;
any other schema mismatch needs separate inspection.
- Never invents mappings or changes roles, memberships, permissions, or other
application records. An empty list means no mappings have been configured.
- Keeps the table and any stored mappings on downgrade, because the table
belongs to the baseline and removing it would delete authorization policy.
After migration, verify both mapping list endpoints return success for an
authorized user in the active tenant, and check the table's constraints and
indexes. Existing read scopes and tenant isolation remain enforced. A missing
table cannot reveal whether historical mappings were once removed: this repair
does not reconstruct lost policy; investigate backups if mappings were expected.
Regression coverage in `tests/test_external_function_mapping_migration.py`
recreates the observed missing-table failure in isolated databases on both
tracks. It checks the HTTP list/delta responses, repeated upgrades, no-op
upgrades with existing mappings, downgrade/re-upgrade preservation, unchanged
parent rows/permissions, constraints, denied unprivileged reads, and tenant
isolation.
## Deutsch
Bei älteren Datenbanken kann die Access-Basismigration als angewendet vermerkt
sein, obwohl `access_external_function_role_assignments` fehlt. Die Liste der
Funktions-Rollenzuordnungen und ihre Delta-API melden dann einen internen Fehler.
Dies ist ein Widerspruch zwischen Schema und Migrationsstand, kein Anlass zur
Erweiterung von Berechtigungen oder zum Neuerstellen von Mandantendaten.
Vor der regulären, vorwärtsgerichteten Migration `d8f1b4e7a0c3` eine überprüfte
Datenbanksicherung erstellen. Den konfigurierten Migrationstrack und den
regulären Bereitstellungsablauf mit installationsweiter Migrationssperre nutzen;
bei einer gezielten Reparatur zuvor die ausstehenden Revisionen prüfen.
Basismigrationen nicht erneut ausführen oder lediglich als angewendet markieren,
keine Entwicklungsdaten initialisieren und die Datenbank nicht zurücksetzen.
Der Entwicklungsstarter kann ausstehende Migrationen bereits beim automatischen
Neuladen des Backends anwenden. Neue Migrationsdateien deshalb außerhalb des
überwachten Quellbaums vorbereiten und testen; Sicherung und Vorprüfung vor dem
Kopieren in den überwachten Quellbaum abschließen. Das Warten mit einem manuellen
Migrationsaufruf verhindert die automatische Anwendung nicht.
Die Reparatur erstellt nur die fehlende Tabelle einschließlich Fremdschlüsseln,
Eindeutigkeitsbedingung und Indizes. Vorhandene Tabellen und Datensätze bleiben
unverändert; auch ein Downgrade entfernt keine Zuordnungsdaten. Teilweise
vorhandene Tabellen werden nicht umgebaut und erfordern eine gesonderte Prüfung.
Es entstehen keine automatischen Zuordnungen oder neuen Rechte. Anschließend
beide Listenendpunkte im aktiven Mandanten mit einer berechtigten Person prüfen.
Eine leere Liste bedeutet, dass keine Zuordnungen konfiguriert sind. Falls früher
Zuordnungen erwartet wurden, Sicherungen prüfen: Verlorene Berechtigungsregeln
lassen sich aus einer fehlenden Tabelle nicht rekonstruieren.
+15
View File
@@ -11,6 +11,21 @@ session is deliberately protected by these operations; use normal logout to end
it. Repeating a revocation is safe. Revoked sessions fail authentication on the it. Repeating a revocation is safe. Revoked sessions fail authentication on the
next request, including when a principal summary was previously cached. next request, including when a principal summary was previously cached.
The shared WebUI clears reusable API response data on explicit authentication,
account, tenant, and permission transitions, changed session/CSRF cookies, and
authentication-expiry responses. Late reads cannot repopulate caches after those
transitions or after a write finishes. `no-store` responses are not retained;
`no-cache` responses require server revalidation, with ETags retained only where
storage is allowed. Reload bypasses older cached responses. These safeguards do
not erase content already displayed by a page: reload that page to reflect
remote changes. The server remains authoritative for every permission check.
Successful interactive sign-in, including re-login, and local sign-out clear
the saved automation API key. It must not shadow the newly established cookie
session with a different principal. Explicitly applying an API key in connection
settings still selects that credential's identity and triggers a new shell
authentication check. Ordinary profile updates in API-key mode retain the key.
Tenant administrators may list sessions only for a membership in their governed Tenant administrators may list sessions only for a membership in their governed
tenant and may revoke only a session belonging to that membership and tenant. tenant and may revoke only a session belonging to that membership and tenant.
The mutation requires both the central membership-update permission and an The mutation requires both the central membership-update permission and an
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.24", "version": "0.1.25",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.24" version = "0.1.25"
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives." description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.43", "govoplan-core>=0.1.45",
"redis>=5,<6", "redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
] ]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN access platform module.""" """GovOPlaN access platform module."""
__version__ = "0.1.24" __version__ = "0.1.25"
@@ -311,6 +311,12 @@ def _rehydrate_cached_principal(
source: str, source: str,
principal: PrincipalRef, principal: PrincipalRef,
) -> ResolvedPrincipalContext | None: ) -> ResolvedPrincipalContext | None:
# A cache hit must retain the same credential-source rules as a cold read.
# API keys are explicit-header credentials, never browser session cookies.
if principal.auth_method not in {"session", "api_key"} or (
principal.auth_method == "api_key" and source == "cookie"
):
return None
account = session.get(Account, principal.account_id) account = session.get(Account, principal.account_id)
user = session.get(User, principal.membership_id) if principal.membership_id else None user = session.get(User, principal.membership_id) if principal.membership_id else None
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
@@ -327,6 +333,10 @@ def _rehydrate_cached_principal(
return None return None
if principal.auth_method == "api_key": if principal.auth_method == "api_key":
# Service accounts have a separate current scope ceiling and lifecycle.
# Do not reuse an ordinary API-key summary for their backing identities.
if account.auth_provider == "service_account" or user.auth_provider == "service_account":
return None
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
if ( if (
api_key is None api_key is None
@@ -408,7 +418,10 @@ def _cache_resolved_principal_context(
identity_directory: IdentityDirectory | None, identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None, organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext: ) -> ResolvedPrincipalContext:
if not settings.auth_principal_cache_enabled: if not settings.auth_principal_cache_enabled or context.principal.auth_method not in {"session", "api_key"}:
# In particular, service-account credentials must keep their dedicated
# provenance and be intersected with the current service-account ceiling
# on every request, not recomputed from interactive membership roles.
return context return context
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id) before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
refreshed = _refresh_principal_context( refreshed = _refresh_principal_context(
+46 -9
View File
@@ -883,7 +883,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
title="Authentication and password fields", title="Authentication and password fields",
summary="Understand which credentials are used for interactive sign-in, initial account enrollment, administrative re-authorization, and automation.", summary="Understand which credentials are used for interactive sign-in, initial account enrollment, administrative re-authorization, and automation.",
body=( body=(
"The sign-in email identifies the account and the password authenticates only that account. Initial passwords entered by administrators are transmitted only for account creation; leaving the field empty asks the server to generate a one-time temporary password. Requiring a password change prevents that temporary credential from becoming the long-term credential. Current-password prompts re-authorize a sensitive action and never target the selected user's password. The automation API key in local settings is used only when no interactive browser session token is available; it should be a narrowly scoped, revocable key and must not be shared with other users. Generated passwords are not applied until Use password is selected." "The sign-in email identifies the account and the password authenticates only that account. Initial passwords entered by administrators are transmitted only for account creation; leaving the field empty asks the server to generate an initial password disclosed once. The password-change-required flag is currently advisory metadata: the self-service password-change workflow and server-side enforcement are not implemented. Do not treat a generated initial password as expiring, single-use for sign-in, or automatically replaced because this flag is set. Current-password prompts re-authorize a sensitive action and never target the selected user's password. Applying an automation API key in local settings explicitly selects that key's identity. Interactive sign-in and sign-out remove the stored key; automation keys should be narrowly scoped, revocable, and never shared with other users. Generated passwords are not applied until Use password is selected."
), ),
layer="always", layer="always",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -900,7 +900,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"title": "Authentifizierungs- und Passwortfelder", "title": "Authentifizierungs- und Passwortfelder",
"summary": "Einordnen, welche Zugangsdaten für die interaktive Anmeldung, die erste Kontoeinrichtung, die erneute administrative Autorisierung und Automatisierung verwendet werden.", "summary": "Einordnen, welche Zugangsdaten für die interaktive Anmeldung, die erste Kontoeinrichtung, die erneute administrative Autorisierung und Automatisierung verwendet werden.",
"body": ( "body": (
"Die Anmelde-E-Mail identifiziert das Konto; das Passwort authentifiziert ausschließlich dieses Konto. Von Administrierenden eingegebene Anfangspasswörter werden nur zur Kontoerstellung übertragen. Bleibt das Feld leer, erzeugt der Server ein einmaliges temporäres Passwort. Die Pflicht zum Passwortwechsel verhindert, dass diese temporäre Zugangsdaten dauerhaft verwendet werden. Die Abfrage des aktuellen Passworts autorisiert eine sensible Aktion erneut und meint niemals das Passwort der ausgewählten Person. Der Automatisierungs-API-Schlüssel in den lokalen Einstellungen wird nur verwendet, wenn kein interaktives Browser-Sitzungstoken verfügbar ist; er sollte eng begrenzt, widerrufbar und nicht mit anderen Personen geteilt sein. Generierte Passwörter werden erst mit „Passwort verwenden“ übernommen." "Die Anmelde-E-Mail identifiziert das Konto; das Passwort authentifiziert ausschließlich dieses Konto. Von Administrierenden eingegebene Anfangspasswörter werden nur zur Kontoerstellung übertragen. Bleibt das Feld leer, erzeugt der Server ein einmal angezeigtes Anfangspasswort. Das Kennzeichen für einen erforderlichen Passwortwechsel ist derzeit nur ein Hinweis in den Metadaten: Der selbstständige Passwortwechsel und die serverseitige Durchsetzung sind noch nicht umgesetzt. Ein generiertes Anfangspasswort läuft durch dieses Kennzeichen weder ab noch ist es nur einmal zur Anmeldung verwendbar oder wird automatisch ersetzt. Die Abfrage des aktuellen Passworts autorisiert eine sensible Aktion erneut und meint niemals das Passwort der ausgewählten Person. Das Anwenden eines Automatisierungs-API-Schlüssels in den lokalen Einstellungen wählt ausdrücklich dessen Identität. Interaktives An- und Abmelden entfernt den gespeicherten Schlüssel. Automatisierungsschlüssel sollen eng begrenzt, widerrufbar und niemals mit anderen Personen geteilt sein. Generierte Passwörter werden erst mit „Passwort verwenden“ übernommen."
), ),
} }
}, },
@@ -920,7 +920,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
id="access.workflow.grant-user-access", id="access.workflow.grant-user-access",
title="Grant a person access", title="Grant a person access",
summary="Use the access administration screens to create or update a tenant membership, place the person in groups, and assign only the roles they need.", summary="Use the access administration screens to create or update a tenant membership, place the person in groups, and assign only the roles they need.",
body="The common path is to find or create the person, review their existing membership, then use groups and roles to grant access. If a role or group is not available, the active governance rules or your own delegation limit may block the change.", body="The common path is to find or create the person, review their existing membership, then use groups and roles to grant access. If a role or group is not available, the active governance rules or your own delegation limit may block the change. Opening lists and creation or edit dialogs does not save changes. If an authorized section reports a load failure, reload after deployment and report the frontend diagnostic; do not broaden permissions or recreate records to work around a render failure.",
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin"), audience=("tenant_admin", "access_admin"),
@@ -961,6 +961,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"nur die benötigten Rollen vergeben." "nur die benötigten Rollen vergeben."
), ),
"body": ( "body": (
"Das Öffnen von Listen sowie Anlage- oder Bearbeitungsdialogen speichert keine Änderungen. Meldet ein berechtigter Bereich einen Ladefehler, laden Sie nach der Bereitstellung neu und melden Sie die Oberflächendiagnose; erweitern Sie keine Berechtigungen und legen Sie keine Datensätze erneut an, um einen Darstellungsfehler zu umgehen. "
"Suchen Sie die Person oder legen Sie sie an, prüfen Sie ihre vorhandene Mitgliedschaft und gewähren Sie den Zugriff " "Suchen Sie die Person oder legen Sie sie an, prüfen Sie ihre vorhandene Mitgliedschaft und gewähren Sie den Zugriff "
"anschließend über Gruppen und Rollen. Ist eine Rolle oder Gruppe nicht verfügbar, können die geltenden " "anschließend über Gruppen und Rollen. Ist eine Rolle oder Gruppe nicht verfügbar, können die geltenden "
"Governance-Regeln oder die eigene Delegationsgrenze die Änderung blockieren." "Governance-Regeln oder die eigene Delegationsgrenze die Änderung blockieren."
@@ -1220,6 +1221,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"Tenant API keys are non-interactive automation credentials owned by an existing tenant user. Select an owner whose current effective permissions contain every requested scope; authorization continues to intersect the stored key scopes with that owner's current permissions, so removing the owner's access also narrows the key. Set the shortest practical expiry and grant only the scopes the client needs. " "Tenant API keys are non-interactive automation credentials owned by an existing tenant user. Select an owner whose current effective permissions contain every requested scope; authorization continues to intersect the stored key scopes with that owner's current permissions, so removing the owner's access also narrows the key. Set the shortest practical expiry and grant only the scopes the client needs. "
"The secret is displayed once after creation. GovOPlaN then retains only its one-way hash and visible prefix, so administrators cannot display or recover it later. Record the value directly in an approved external secret manager and close the one-time dialog only after custody is confirmed. " "The secret is displayed once after creation. GovOPlaN then retains only its one-way hash and visible prefix, so administrators cannot display or recover it later. Record the value directly in an approved external secret manager and close the one-time dialog only after custody is confirmed. "
"Revocation is immediate and irreversible for that key: existing clients lose access and must be configured with a newly issued credential. Inspect and audit views expose metadata, scopes, timestamps, and the non-authenticating prefix but never secret material." "Revocation is immediate and irreversible for that key: existing clients lose access and must be configured with a newly issued credential. Inspect and audit views expose metadata, scopes, timestamps, and the non-authenticating prefix but never secret material."
" Clients must send API keys through Authorization: Bearer or X-API-Key, never the browser session cookie; cached authentication does not relax this rule. Effective key permissions remain tenant-only: module wildcards expand to currently registered concrete tenant permissions, never instance-wide permissions or unknown wildcard grants. Existing concrete tenant grants and their compatibility aliases remain valid."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -1252,7 +1254,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"de": { "de": {
"title": "Mandanten-API-Schlüssel erstellen und widerrufen", "title": "Mandanten-API-Schlüssel erstellen und widerrufen",
"summary": "Geben Sie ein einmal sichtbares Automatisierungsgeheimnis für eine verantwortliche Person aus, begrenzen Sie Umfang und Laufzeit und widerrufen Sie den Schlüssel, sobald der Zugriff enden muss.", "summary": "Geben Sie ein einmal sichtbares Automatisierungsgeheimnis für eine verantwortliche Person aus, begrenzen Sie Umfang und Laufzeit und widerrufen Sie den Schlüssel, sobald der Zugriff enden muss.",
"body": "Mandanten-API-Schlüssel sind nicht interaktive Automatisierungszugänge einer vorhandenen Person im Mandanten. Wählen Sie eine verantwortliche Person, deren aktuelle wirksame Berechtigungen alle gewünschten Scopes enthalten. Bei jeder Nutzung werden die gespeicherten Schlüssel-Scopes weiterhin mit den aktuellen Berechtigungen dieser Person geschnitten; ein Entzug ihrer Berechtigungen schränkt daher auch den Schlüssel ein. Legen Sie die kürzeste praktikable Laufzeit fest und vergeben Sie nur die Scopes, die der Client tatsächlich benötigt. Das Geheimnis wird nach der Erstellung genau einmal angezeigt. Danach speichert GovOPlaN nur einen Einweg-Hash und das sichtbare, nicht zur Anmeldung geeignete Präfix; eine spätere Anzeige oder Wiederherstellung ist nicht möglich. Übertragen Sie den Wert unmittelbar in einen freigegebenen externen Geheimnismanager und schließen Sie den Einmal-Dialog erst nach bestätigter Verwahrung. Ein Widerruf wirkt sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden: Bestehende Clients verlieren den Zugriff und benötigen einen neu ausgegebenen Zugang. Detail- und Auditansichten zeigen Metadaten, Scopes, Zeitpunkte und das Präfix, aber niemals das Geheimnis.", "body": "Mandanten-API-Schlüssel sind nicht interaktive Automatisierungszugänge einer vorhandenen Person im Mandanten. Wählen Sie eine verantwortliche Person, deren aktuelle wirksame Berechtigungen alle gewünschten Scopes enthalten. Bei jeder Nutzung werden die gespeicherten Schlüssel-Scopes weiterhin mit den aktuellen Berechtigungen dieser Person geschnitten; ein Entzug ihrer Berechtigungen schränkt daher auch den Schlüssel ein. Legen Sie die kürzeste praktikable Laufzeit fest und vergeben Sie nur die Scopes, die der Client tatsächlich benötigt. Das Geheimnis wird nach der Erstellung genau einmal angezeigt. Danach speichert GovOPlaN nur einen Einweg-Hash und das sichtbare, nicht zur Anmeldung geeignete Präfix; eine spätere Anzeige oder Wiederherstellung ist nicht möglich. Übertragen Sie den Wert unmittelbar in einen freigegebenen externen Geheimnismanager und schließen Sie den Einmal-Dialog erst nach bestätigter Verwahrung. Ein Widerruf wirkt sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden: Bestehende Clients verlieren den Zugriff und benötigen einen neu ausgegebenen Zugang. Detail- und Auditansichten zeigen Metadaten, Scopes, Zeitpunkte und das Präfix, aber niemals das Geheimnis. Clients müssen API-Schlüssel über Authorization: Bearer oder X-API-Key senden, niemals über das Browsersitzungs-Cookie; zwischengespeicherte Authentifizierung lockert diese Regel nicht. Wirksame Schlüsselrechte bleiben mandantenbezogen: Modulplatzhalter werden nur in aktuell registrierte konkrete Mandantenberechtigungen aufgelöst, niemals in instanzweite Berechtigungen oder unbekannte Platzhalterrechte. Vorhandene konkrete Mandantenrechte und ihre Kompatibilitätsnamen bleiben gültig.",
} }
}, },
metadata={ metadata={
@@ -1303,6 +1305,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
summary="Reusable credential envelopes keep secrets write-only while administrators constrain which scopes, modules, and servers may use them.", summary="Reusable credential envelopes keep secrets write-only while administrators constrain which scopes, modules, and servers may use them.",
body=( body=(
"A reusable credential envelope stores a secret behind the Access boundary and never returns the configured secret through the API. Choose the credential type before entering the secret; changing the type requires a replacement secret. When editing, an empty secret field retains the current value, while Remove configured secret clears it on save and leaves dependent connections unable to authenticate until a replacement is supplied. " "A reusable credential envelope stores a secret behind the Access boundary and never returns the configured secret through the API. Choose the credential type before entering the secret; changing the type requires a replacement secret. When editing, an empty secret field retains the current value, while Remove configured secret clears it on save and leaves dependent connections unable to authenticate until a replacement is supplied. "
"Server reference labels are resolved when the editor opens. Failed saves show the error inside the dialog and retain your entered draft for an explicit retry; a pending save prevents duplicate submission and dismissal. The clear-secret switch aligns with the adjacent input control, including wrapped labels and narrow layouts. "
"The module and server lists are restrictions: an empty list means every module or server already permitted by the selected scope. Visible to lower scopes makes the envelope selectable from child scopes but does not bypass its module, server, or authorization limits. Deactivating keeps the configuration for review but blocks authentication. Deleting is irreversible in GovOPlaN, cannot recover the secret, and causes every referencing connection to stop authenticating. Review dependent connections and record the external secret-manager owner before clearing or deleting a credential." "The module and server lists are restrictions: an empty list means every module or server already permitted by the selected scope. Visible to lower scopes makes the envelope selectable from child scopes but does not bypass its module, server, or authorization limits. Deactivating keeps the configuration for review but blocks authentication. Deleting is irreversible in GovOPlaN, cannot recover the secret, and causes every referencing connection to stop authenticating. Review dependent connections and record the external secret-manager owner before clearing or deleting a credential."
), ),
layer="configured", layer="configured",
@@ -1340,7 +1343,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"de": { "de": {
"title": "Wiederverwendbare Zugangsdaten sicher verwalten", "title": "Wiederverwendbare Zugangsdaten sicher verwalten",
"summary": "Wiederverwendbare Zugangsdaten geben Geheimnisse nicht wieder aus und begrenzen ihre Nutzung auf freigegebene Ebenen, Module und Server.", "summary": "Wiederverwendbare Zugangsdaten geben Geheimnisse nicht wieder aus und begrenzen ihre Nutzung auf freigegebene Ebenen, Module und Server.",
"body": "Ein Eintrag für wiederverwendbare Zugangsdaten speichert ein Geheimnis hinter der Access-Sicherheitsgrenze; das konfigurierte Geheimnis wird über die API niemals zurückgegeben. Wählen Sie den Zugangstyp vor der Eingabe. Eine Typänderung erfordert ein neues Geheimnis. Beim Bearbeiten behält ein leeres Geheimnisfeld den vorhandenen Wert. Mit „Konfiguriertes Geheimnis entfernen“ wird er beim Speichern gelöscht; abhängige Verbindungen können sich erst nach Hinterlegung eines Ersatzes wieder anmelden. Die Modul- und Serverlisten sind Einschränkungen: Eine leere Liste erlaubt alle Module beziehungsweise Server, die auf der gewählten Ebene bereits zulässig sind. „Für tiefere Ebenen sichtbar“ macht den Eintrag in Kindebenen auswählbar, umgeht aber weder Modul- und Servergrenzen noch Berechtigungen. Eine Deaktivierung erhält die Konfiguration zur Prüfung, verhindert jedoch die Anmeldung. Das Löschen kann in GovOPlaN nicht rückgängig gemacht werden, stellt das Geheimnis nicht wieder her und unterbricht die Anmeldung aller referenzierenden Verbindungen. Prüfen Sie deshalb vor dem Entfernen oder Löschen die abhängigen Verbindungen und die Zuständigkeit im externen Geheimnismanager.", "body": "Beim Öffnen des Editors werden die Serverbezeichnungen aufgelöst. Ein fehlgeschlagenes Speichern zeigt den Fehler im Dialog und erhält Ihre Eingaben für einen ausdrücklichen erneuten Versuch. Während des Speicherns sind erneutes Absenden und Schließen gesperrt. Der Schalter zum Entfernen des Geheimnisses richtet sich auch bei mehrzeiligen Beschriftungen und schmalen Ansichten am benachbarten Eingabefeld aus. Ein Eintrag für wiederverwendbare Zugangsdaten speichert ein Geheimnis hinter der Access-Sicherheitsgrenze; das konfigurierte Geheimnis wird über die API niemals zurückgegeben. Wählen Sie den Zugangstyp vor der Eingabe. Eine Typänderung erfordert ein neues Geheimnis. Beim Bearbeiten behält ein leeres Geheimnisfeld den vorhandenen Wert. Mit „Konfiguriertes Geheimnis entfernen“ wird er beim Speichern gelöscht; abhängige Verbindungen können sich erst nach Hinterlegung eines Ersatzes wieder anmelden. Die Modul- und Serverlisten sind Einschränkungen: Eine leere Liste erlaubt alle Module beziehungsweise Server, die auf der gewählten Ebene bereits zulässig sind. „Für tiefere Ebenen sichtbar“ macht den Eintrag in Kindebenen auswählbar, umgeht aber weder Modul- und Servergrenzen noch Berechtigungen. Eine Deaktivierung erhält die Konfiguration zur Prüfung, verhindert jedoch die Anmeldung. Das Löschen kann in GovOPlaN nicht rückgängig gemacht werden, stellt das Geheimnis nicht wieder her und unterbricht die Anmeldung aller referenzierenden Verbindungen. Prüfen Sie deshalb vor dem Entfernen oder Löschen die abhängigen Verbindungen und die Zuständigkeit im externen Geheimnismanager.",
} }
}, },
metadata={ metadata={
@@ -1401,6 +1404,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"Service accounts are tenant-owned automation principals. The account itself has no password or interactive session. Administrators first define its scope ceiling, then create one or more independently revocable credentials. " "Service accounts are tenant-owned automation principals. The account itself has no password or interactive session. Administrators first define its scope ceiling, then create one or more independently revocable credentials. "
"A credential secret is disclosed once and only its hash and prefix remain in GovOPlaN. Runtime authorization is always the intersection of the credential scopes and the service account's current ceiling, so lowering the ceiling or deactivating the account takes effect immediately. " "A credential secret is disclosed once and only its hash and prefix remain in GovOPlaN. Runtime authorization is always the intersection of the credential scopes and the service account's current ceiling, so lowering the ceiling or deactivating the account takes effect immediately. "
"Rotation creates the replacement and revokes the previous credential in one transaction. Retirement disables the backing principal and revokes every active credential. Every credential mutation requires the current service-account revision; a stale browser must reload instead of overwriting a concurrent change." "Rotation creates the replacement and revokes the previous credential in one transaction. Retirement disables the backing principal and revokes every active credential. Every credential mutation requires the current service-account revision; a stale browser must reload instead of overwriting a concurrent change."
" Authentication keeps service-account provenance and rechecks the current ceiling and lifecycle on every request, without using interactive principal summaries or membership roles. Older credentials cannot regain removed permissions through a cached role assignment. Service-account credentials use explicit authorization headers and remain tenant-only even if their stored grant contains a broader wildcard or system permission."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -1436,7 +1440,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"de": { "de": {
"title": "Dienstkonten und ihre Zugangsdaten verwalten", "title": "Dienstkonten und ihre Zugangsdaten verwalten",
"summary": "Erstellen Sie nicht interaktive Automatisierungsidentitäten, begrenzen Sie deren aktuellen Berechtigungsrahmen und rotieren Sie einmal sichtbare Zugangsdaten ohne menschliche Anmeldung.", "summary": "Erstellen Sie nicht interaktive Automatisierungsidentitäten, begrenzen Sie deren aktuellen Berechtigungsrahmen und rotieren Sie einmal sichtbare Zugangsdaten ohne menschliche Anmeldung.",
"body": "Dienstkonten sind mandanteneigene Automatisierungsidentitäten ohne Passwort und ohne interaktive Sitzung. Administrierende legen zuerst den Berechtigungsrahmen des Kontos fest und erstellen danach eine oder mehrere unabhängig widerrufbare Zugangsdaten. Jede Zugangsdaten-Berechtigung muss innerhalb dieses Rahmens liegen. Bei jeder Anfrage wird sie erneut mit dem aktuellen Rahmen geschnitten; eine Verkleinerung des Rahmens oder eine Deaktivierung wirkt deshalb sofort. Das Geheimnis wird nur bei Erstellung oder Rotation einmal angezeigt. GovOPlaN speichert anschließend ausschließlich einen Einweg-Hash und das sichtbare Präfix; das Geheimnis kann weder angezeigt noch wiederhergestellt werden. Eine Rotation erzeugt in einer Transaktion den Ersatz und widerruft die vorherigen Zugangsdaten. Ein Widerruf unterbricht bestehende Clients sofort. Die Deaktivierung stoppt alle Anmeldungen des Dienstkontos, kann aber wieder aufgehoben werden. Das endgültige Stilllegen deaktiviert die zugrunde liegende Identität und widerruft sämtliche aktiven Zugangsdaten. Jede Änderung verwendet die aktuelle Revision des Dienstkontos; bei einem Konflikt muss die Ansicht neu geladen werden, damit keine parallele Änderung überschrieben wird.", "body": "Dienstkonten sind mandanteneigene Automatisierungsidentitäten ohne Passwort und ohne interaktive Sitzung. Administrierende legen zuerst den Berechtigungsrahmen des Kontos fest und erstellen danach eine oder mehrere unabhängig widerrufbare Zugangsdaten. Jede Zugangsdaten-Berechtigung muss innerhalb dieses Rahmens liegen. Bei jeder Anfrage wird sie erneut mit dem aktuellen Rahmen geschnitten; eine Verkleinerung des Rahmens oder eine Deaktivierung wirkt deshalb sofort. Das Geheimnis wird nur bei Erstellung oder Rotation einmal angezeigt. GovOPlaN speichert anschließend ausschließlich einen Einweg-Hash und das sichtbare Präfix; das Geheimnis kann weder angezeigt noch wiederhergestellt werden. Eine Rotation erzeugt in einer Transaktion den Ersatz und widerruft die vorherigen Zugangsdaten. Ein Widerruf unterbricht bestehende Clients sofort. Die Deaktivierung stoppt alle Anmeldungen des Dienstkontos, kann aber wieder aufgehoben werden. Das endgültige Stilllegen deaktiviert die zugrunde liegende Identität und widerruft sämtliche aktiven Zugangsdaten. Jede Änderung verwendet die aktuelle Revision des Dienstkontos; bei einem Konflikt muss die Ansicht neu geladen werden, damit keine parallele Änderung überschrieben wird. Die Authentifizierung behält die Dienstkonto-Herkunft bei und prüft Berechtigungsrahmen sowie Lebenszyklus bei jeder Anfrage erneut, ohne interaktive Principal-Zwischenspeicher oder Mitgliedschaftsrollen zu verwenden. Ältere Zugangsdaten erhalten entzogene Rechte nicht durch eine zwischengespeicherte Rollenzuweisung zurück. Dienstkonto-Zugangsdaten verwenden ausdrückliche Autorisierungsheader und bleiben mandantenbezogen, selbst wenn ihre gespeicherte Freigabe einen weitergehenden Platzhalter oder ein Systemrecht enthält.",
} }
}, },
metadata={ metadata={
@@ -1497,6 +1501,9 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
summary="Users can reorder or hide available navigation entries without changing access or other users' workspaces.", summary="Users can reorder or hide available navigation entries without changing access or other users' workspaces.",
body=( body=(
"Open Settings and use the workspace navigation editor to move or show available entries. Personal order and visibility take precedence over tenant and system preferences. Entries locked by a system or tenant administrator remain visible, and a user cannot create a lock. Choosing the inherited order removes the personal layer. Module entitlement, View policy, and permissions continue to decide which destinations are available, so changing navigation never grants access." "Open Settings and use the workspace navigation editor to move or show available entries. Personal order and visibility take precedence over tenant and system preferences. Entries locked by a system or tenant administrator remain visible, and a user cannot create a lock. Choosing the inherited order removes the personal layer. Module entitlement, View policy, and permissions continue to decide which destinations are available, so changing navigation never grants access."
" The same editor is used for system, tenant, personal, and View layouts. Drag handles reorder modules and separators together; arrow buttons provide single-step moves. On a focused handle use Space to pick up, arrow keys to move, Enter to drop, or Escape to cancel. Add module restores a removed available entry; removing it only hides navigation and never uninstalls the module or deletes data. Add separator creates an optional group label, and separators remain horizontal rules when the rail is collapsed. Opening the editor leaves the draft clean; Save on the owning page persists changes, while inherited layout removes the override. View surface restrictions still apply, and an explicit personal layout takes precedence over the active View's presentation order."
" Expanded navigation shows group headings without divider lines; collapsing it replaces those headings with horizontal separators between groups."
" A temporarily failed module interface import is retried once after a short delay. If it still fails, the shell names the affected enabled modules and offers guarded Reload without changing entitlements, View restrictions, or configured data. Save or explicitly discard pending work before reloading; a missing interface is not evidence that the module was uninstalled."
), ),
layer="configured", layer="configured",
documentation_types=("user", "admin"), documentation_types=("user", "admin"),
@@ -1523,6 +1530,9 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"gesperrte Einträge bleiben sichtbar; Benutzende können selbst keine Sperre erzeugen. Die Auswahl der geerbten Reihenfolge " "gesperrte Einträge bleiben sichtbar; Benutzende können selbst keine Sperre erzeugen. Die Auswahl der geerbten Reihenfolge "
"entfernt die persönliche Ebene. Modulberechtigung, View-Richtlinie und Zugriffsrechte bestimmen weiterhin, welche Ziele " "entfernt die persönliche Ebene. Modulberechtigung, View-Richtlinie und Zugriffsrechte bestimmen weiterhin, welche Ziele "
"verfügbar sind. Eine Navigationsänderung gewährt daher niemals zusätzlichen Zugriff." "verfügbar sind. Eine Navigationsänderung gewährt daher niemals zusätzlichen Zugriff."
" Derselbe Editor wird für System-, Mandanten-, persönliche und View-Anordnungen verwendet. Ziehgriffe ordnen Module und Trennlinien gemeinsam an; Pfeilschaltflächen verschieben schrittweise. Am fokussierten Griff nimmt die Leertaste auf, Pfeiltasten verschieben, Eingabe legt ab und Escape bricht ab. Modul hinzufügen stellt einen entfernten verfügbaren Eintrag wieder her; Entfernen blendet nur die Navigation aus und deinstalliert weder ein Modul noch löscht es Daten. Trennlinie hinzufügen erlaubt eine optionale Gruppenbezeichnung; auch die eingeklappte Leiste zeigt horizontale Trennlinien. Das Öffnen erzeugt keine ungespeicherten Änderungen. Speichern auf der jeweiligen Seite übernimmt die Anordnung, die geerbte Anordnung entfernt die Ausnahme. View-Oberflächenbeschränkungen bleiben wirksam; eine ausdrückliche persönliche Anordnung hat Vorrang vor der Darstellungsreihenfolge der aktiven View."
" Die ausgeklappte Navigation zeigt Gruppenüberschriften ohne Trennlinien; beim Einklappen werden die Überschriften durch horizontale Linien zwischen den Gruppen ersetzt."
" Ein vorübergehend fehlgeschlagener Import einer Moduloberfläche wird nach kurzer Wartezeit einmal wiederholt. Bei erneutem Fehler nennt die Oberfläche die betroffenen aktivierten Module und bietet geschütztes Neuladen an; Modulberechtigungen, View-Beschränkungen und konfigurierte Daten bleiben unverändert. Speichern oder verwerfen Sie ausstehende Änderungen ausdrücklich vor dem Neuladen. Eine fehlende Oberfläche bedeutet nicht, dass das Modul deinstalliert wurde."
), ),
} }
}, },
@@ -1532,13 +1542,30 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"outcome": "The user's side rail reflects the personal preference while locked and inaccessible entries remain governed by higher-level policy.", "outcome": "The user's side rail reflects the personal preference while locked and inaccessible entries remain governed by higher-level policy.",
}, },
), ),
DocumentationTopic(
id="access.reference.shared-interface-controls",
title="Use shared tables and dialogs",
summary="Resize table columns and use compact dialogs without losing action controls.",
body="Shared DataGrid action columns reserve room for their actual controls and wrap action groups in narrow containers. Wide data retains a local horizontal scrollbar; an oversized pinned column becomes scrollable rather than covering the entire table. Drag a column boundary, or focus it and use Left/Right for 10-pixel changes and Shift for 40 pixels. Enter or double-click resets that column; Escape cancels an active drag without closing its dialog. Widths are browser-local and do not change other users or stored data. The sizing-contract update resets incompatible legacy width preferences once while preserving filters and sort choices. Dialog form fields shrink within the available panel; only intentionally wide content such as a table scrolls horizontally inside its own region. Reload sits in the top-right action group immediately before New on collection pages; editable pages keep Save last and protect unsaved changes on Reload. Table-only cards have full-width bodies; explanatory sections remain padded. List-filter dropdowns use checkboxes with Select all and Deselect all; selecting several values matches any of them, while selecting none shows no matches. Filter and layout choices do not grant access or modify records.",
documentation_types=("user", "admin"),
audience=("user", "tenant_admin", "system_admin"),
related_modules=("admin", "templates"),
metadata={"kind": "reference"},
translations={"de": {
"title": "Gemeinsame Tabellen und Dialoge bedienen",
"summary": "Tabellenspalten anpassen und kompakte Dialoge bedienen, ohne Aktionsschaltflächen zu verlieren.",
"body": "Aktionsspalten der zentralen DataGrid reservieren Platz für ihre tatsächlichen Bedienelemente; in schmalen Bereichen brechen Aktionsgruppen um. Breite Daten behalten eine lokale horizontale Bildlaufleiste. Eine übergroße angeheftete Spalte wird mitscrollbar, statt die gesamte Tabelle zu verdecken. Ziehen Sie eine Spaltengrenze oder fokussieren Sie sie und verwenden Links/Rechts für 10-Pixel-Schritte, mit Umschalt für 40 Pixel. Eingabe oder Doppelklick setzt diese Spalte zurück. Escape bricht einen laufenden Ziehvorgang ab, ohne den Dialog zu schließen. Breiten bleiben browserlokal und verändern weder andere Benutzer noch gespeicherte Daten. Die aktualisierte Größenberechnung setzt unvereinbare alte Breitenpräferenzen einmalig zurück; Filter und Sortierung bleiben erhalten. Formularfelder passen sich an den verfügbaren Dialog an. Nur bewusst breite Inhalte wie Tabellen scrollen horizontal in ihrem eigenen Bereich. Neu laden steht bei Sammlungsseiten oben rechts unmittelbar vor Neu. Bearbeitbare Seiten behalten Speichern als letzte Aktion und schützen ungespeicherte Änderungen beim Neuladen. Reine Tabellenkarten nutzen die volle Breite; erläuternde Abschnitte behalten Innenabstand. Listenfilter bieten Kontrollkästchen mit Alle auswählen und Alle abwählen: mehrere Werte schließen jeden davon ein, keine Auswahl zeigt keine Treffer. Filter und Layout erteilen weder Zugriff noch verändern sie Datensätze."
}},
),
DocumentationTopic( DocumentationTopic(
id="access.workflow.manage-sessions", id="access.workflow.manage-sessions",
title="Review and revoke account sessions", title="Review and revoke account sessions",
summary="Inspect active browser sessions and revoke one or every other session without exposing credentials or network identifiers.", summary="Inspect active browser sessions and revoke one or every other session without exposing credentials or network identifiers.",
body=( body=(
"Settings > Sessions and devices marks the current browser session and shows only bounded client metadata plus creation, last-seen, and expiry times. " "Settings > Sessions and devices marks the current browser session and shows only bounded client metadata plus creation, last-seen, and expiry times. "
"Interactive sign-in and sign-out clear the browser's saved automation API key so it cannot override the selected session identity. Applying an API key explicitly in connection settings selects that key's identity. "
"Users can revoke another session or all other active sessions; the command session is protected and normal logout remains the way to end it. Revocation is idempotent and takes effect on the next authenticated request. " "Users can revoke another session or all other active sessions; the command session is protected and normal logout remains the way to end it. Revocation is idempotent and takes effect on the next authenticated request. "
"The shared browser client discards reusable response data when the session, account, tenant, or permissions change and when authentication expires. It honors no-store and no-cache responses; conditional responses are revalidated by the server. Late reads cannot repopulate caches after a save or session change. Reload bypasses older cached reads without changing records; an already displayed page still requires refresh to reflect remote changes. "
"Tenant administrators can inspect only sessions belonging to a membership in their governed tenant. Administrative revocation requires central membership-update permission and current-password re-authorization from an interactive session. " "Tenant administrators can inspect only sessions belonging to a membership in their governed tenant. Administrative revocation requires central membership-update permission and current-password re-authorization from an interactive session. "
"Audit evidence records stable actors, targets, and counts without tokens, hashes, cookies, IP addresses, or client strings." "Audit evidence records stable actors, targets, and counts without tokens, hashes, cookies, IP addresses, or client strings."
), ),
@@ -1572,7 +1599,9 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"summary": "Aktive Browsersitzungen prüfen und einzelne oder alle anderen Sitzungen widerrufen, ohne Zugangsdaten oder Netzwerkkennungen offenzulegen.", "summary": "Aktive Browsersitzungen prüfen und einzelne oder alle anderen Sitzungen widerrufen, ohne Zugangsdaten oder Netzwerkkennungen offenzulegen.",
"body": ( "body": (
"Einstellungen > Sitzungen und Geräte kennzeichnet die aktuelle Browsersitzung und zeigt nur begrenzte Clientmetadaten sowie Erstellungs-, Aktivitäts- und Ablaufzeitpunkte. " "Einstellungen > Sitzungen und Geräte kennzeichnet die aktuelle Browsersitzung und zeigt nur begrenzte Clientmetadaten sowie Erstellungs-, Aktivitäts- und Ablaufzeitpunkte. "
"Interaktives An- und Abmelden entfernt den gespeicherten Automatisierungs-API-Schlüssel, damit dieser nicht die gewählte Sitzungsidentität übersteuert. Das ausdrückliche Anwenden eines API-Schlüssels in den Verbindungseinstellungen wählt dessen Identität. "
"Benutzende können eine andere oder alle anderen aktiven Sitzungen widerrufen; die ausführende Sitzung bleibt geschützt und wird regulär abgemeldet. Der Widerruf ist idempotent und gilt beim nächsten authentifizierten Aufruf. " "Benutzende können eine andere oder alle anderen aktiven Sitzungen widerrufen; die ausführende Sitzung bleibt geschützt und wird regulär abgemeldet. Der Widerruf ist idempotent und gilt beim nächsten authentifizierten Aufruf. "
"Der gemeinsame Browserclient verwirft wiederverwendbare Antwortdaten beim Wechsel von Sitzung, Konto, Mandant oder Berechtigungen sowie bei abgelaufener Anmeldung. Er beachtet no-store und no-cache; bedingte Antworten werden vom Server erneut geprüft. Verspätete Leseantworten füllen nach dem Speichern oder Sitzungswechsel keine Zwischenspeicher erneut. Neu laden umgeht ältere zwischengespeicherte Antworten, ohne Datensätze zu ändern. Eine bereits angezeigte Seite muss weiterhin aktualisiert werden, um entfernte Änderungen darzustellen. "
"Mandantenadministrierende sehen nur Sitzungen einer Mitgliedschaft im verwalteten Mandanten. Der administrative Widerruf erfordert die zentrale Berechtigung zur Mitgliedschaftsänderung und eine erneute Passwortbestätigung in einer interaktiven Sitzung. " "Mandantenadministrierende sehen nur Sitzungen einer Mitgliedschaft im verwalteten Mandanten. Der administrative Widerruf erfordert die zentrale Berechtigung zur Mitgliedschaftsänderung und eine erneute Passwortbestätigung in einer interaktiven Sitzung. "
"Auditnachweise speichern stabile Akteure, Ziele und Anzahlen, aber keine Token, Hashes, Cookies, IP-Adressen oder Clienttexte." "Auditnachweise speichern stabile Akteure, Ziele und Anzahlen, aber keine Token, Hashes, Cookies, IP-Adressen oder Clienttexte."
), ),
@@ -1614,7 +1643,11 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"The assignment itself does not grant rights. Removing the IDM assignment, disabling the Organizations function, or removing the Access mapping stops the derived role source from contributing effective permissions. " "The assignment itself does not grant rights. Removing the IDM assignment, disabling the Organizations function, or removing the Access mapping stops the derived role source from contributing effective permissions. "
"A delegated assignment contributes under the delegate's own account. An acting-for assignment contributes only after an interactive session selects that exact current assignment; Access retains both the real and represented account, audits context changes, and rejects stale or mismatched selections. " "A delegated assignment contributes under the delegate's own account. An acting-for assignment contributes only after an interactive session selects that exact current assignment; Access retains both the real and represented account, audits context changes, and rejects stale or mismatched selections. "
"Tenant administrators inspect this from the user access explanation dialog: role sources link back to the Organizations function or unit that defines the fact and to the IDM assignment that produced it. " "Tenant administrators inspect this from the user access explanation dialog: role sources link back to the Organizations function or unit that defines the fact and to the IDM assignment that produced it. "
"The same explanation is exposed by the admin API, while mapping management remains under Admin > Function role mappings. This keeps the audit trail clear: Organizations records what can exist, IDM records who holds it, and Access records which accepted facts produce permissions." "The same explanation is exposed by the admin API, while mapping management remains under Admin > Function role mappings. This keeps the audit trail clear: Organizations records what can exist, IDM records who holds it, and Access records which accepted facts produce permissions. "
"An empty mapping list means no external function-to-role policy is configured; loading or repairing the list never creates mappings or grants rights. "
"If an older database records the Access baseline but lacks the external mapping table, operators must back up the database and apply the normal forward Access migration d8f1b4e7a0c3. "
"This additive repair creates only the missing table with its constraints and indexes, leaves an existing table and all roles, memberships, and mappings unchanged, and retains mapping data on downgrade. Do not replay or stamp the baseline, reset the database, or broaden permissions to work around a missing-table error. "
"Development file-watch reloads can automatically run migrations: complete the backup and revision preflight before adding migration files to a running instance's watched source tree."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -1679,7 +1712,11 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"Rollenquellen verweisen auf die definierende Organizations-Funktion oder -Einheit und auf die erzeugende IDM-Zuweisung. " "Rollenquellen verweisen auf die definierende Organizations-Funktion oder -Einheit und auf die erzeugende IDM-Zuweisung. "
"Dieselbe Erklärung steht über die Admin-API bereit, während Zuordnungen unter Admin > Funktions-Rollenzuordnungen verwaltet " "Dieselbe Erklärung steht über die Admin-API bereit, während Zuordnungen unter Admin > Funktions-Rollenzuordnungen verwaltet "
"werden. So bleibt die Verantwortung nachvollziehbar: Organizations definiert, was existieren kann, IDM hält fest, wer es " "werden. So bleibt die Verantwortung nachvollziehbar: Organizations definiert, was existieren kann, IDM hält fest, wer es "
"innehat, und Access bestimmt, welche bestätigten Merkmale Berechtigungen erzeugen." "innehat, und Access bestimmt, welche bestätigten Merkmale Berechtigungen erzeugen. "
"Eine leere Zuordnungsliste bedeutet, dass keine externe Funktions-Rollenregel konfiguriert ist; das Laden oder Reparieren der Liste erzeugt weder Zuordnungen noch Rechte. "
"Ist in einer älteren Datenbank die Access-Basismigration vermerkt, fehlt aber die externe Zuordnungstabelle, müssen Betreibende die Datenbank sichern und die reguläre vorwärtsgerichtete Access-Migration d8f1b4e7a0c3 anwenden. "
"Diese additive Reparatur erstellt ausschließlich die fehlende Tabelle einschließlich Bedingungen und Indizes, lässt eine vorhandene Tabelle sowie Rollen, Mitgliedschaften und Zuordnungen unverändert und erhält Zuordnungsdaten auch beim Downgrade. Die Basismigration nicht erneut ausführen oder als angewendet markieren, die Datenbank nicht zurücksetzen und Berechtigungen nicht wegen eines Fehlers über eine fehlende Tabelle erweitern. "
"Das automatische Neuladen im Entwicklungsbetrieb kann Migrationen ausführen: Sicherung und Revisionsprüfung abschließen, bevor neue Migrationsdateien in den überwachten Quellbaum einer laufenden Instanz gelangen."
), ),
} }
}, },
@@ -2032,7 +2069,7 @@ def _people_search(context: ModuleContext) -> object:
manifest = ModuleManifest( manifest = ModuleManifest(
id="access", id="access",
name="Access", name="Access",
version="0.1.24", version="0.1.25",
optional_dependencies=("identity", "organizations", "tenancy", "idm"), optional_dependencies=("identity", "organizations", "tenancy", "idm"),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
@@ -0,0 +1,62 @@
"""Repair missing external function role mappings without replaying the baseline.
Revision ID: d8f1b4e7a0c3
Revises: b6d9f2a5c8e1
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8f1b4e7a0c3"
down_revision = "b6d9f2a5c8e1"
branch_labels = None
depends_on = "4f2a9c8e7b6d"
TABLE_NAME = "access_external_function_role_assignments"
def upgrade() -> None:
# Some older installations record the Access baseline without this table.
# Never recreate an existing mapping table or derive permission grants.
if sa.inspect(op.get_bind()).has_table(TABLE_NAME):
return
op.create_table(
TABLE_NAME,
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("source_module", sa.String(length=50), nullable=False),
sa.Column("function_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["role_id"],
["access_roles.id"],
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
sa.UniqueConstraint(
"tenant_id", "source_module", "function_id", "role_id",
name="uq_external_function_role_assignments",
),
)
for column in ("function_id", "role_id", "source_module", "tenant_id"):
op.create_index(op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False)
def downgrade() -> None:
# The table belongs to the baseline, not this repair. Keep mappings created
# before or after repair; dropping it would silently remove permission policy.
pass
@@ -0,0 +1,62 @@
"""Repair missing external function role mappings without replaying the baseline.
Revision ID: d8f1b4e7a0c3
Revises: c7e0a3d6f9b2
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8f1b4e7a0c3"
down_revision = "c7e0a3d6f9b2"
branch_labels = None
depends_on = None
TABLE_NAME = "access_external_function_role_assignments"
def upgrade() -> None:
# Some older installations record the Access baseline without this table.
# Never recreate an existing mapping table or derive permission grants.
if sa.inspect(op.get_bind()).has_table(TABLE_NAME):
return
op.create_table(
TABLE_NAME,
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("source_module", sa.String(length=50), nullable=False),
sa.Column("function_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["role_id"],
["access_roles.id"],
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
sa.UniqueConstraint(
"tenant_id", "source_module", "function_id", "role_id",
name="uq_external_function_role_assignments",
),
)
for column in ("function_id", "role_id", "source_module", "tenant_id"):
op.create_index(op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False)
def downgrade() -> None:
# The table belongs to the baseline, not this repair. Keep mappings created
# before or after repair; dropping it would silently remove permission policy.
pass
@@ -212,11 +212,29 @@ def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[st
allowed.update( allowed.update(
scope scope
for scope in user_raw.intersection(key_raw) for scope in user_raw.intersection(key_raw)
if not scope.startswith("system:") and scope not in {"*", "tenant:*"} if _is_concrete_tenant_credential_scope(scope, catalog)
) )
return sorted(allowed) return sorted(allowed)
def _is_concrete_tenant_credential_scope(
scope: str,
catalog: Mapping[str, PermissionDefinition],
) -> bool:
# Wildcards are expanded against the tenant catalogue above. Returning the
# wildcard itself could grant system permissions sharing the module prefix,
# or permissions outside the currently known tenant catalogue.
if scope == "*" or scope.endswith(":*"):
return False
# System permissions can use module-native names (e.g. access:tenant:create),
# so excluding only the historical system: prefix is not sufficient.
return all(
not alias.startswith("system:")
and (alias not in catalog or catalog[alias].level == "tenant")
for alias in compatible_required_scopes(scope)
)
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]: def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
registry = _registry() registry = _registry()
if registry is not None and hasattr(registry, "permissions"): if registry is not None and hasattr(registry, "permissions"):
@@ -68,8 +68,17 @@ def list_account_sessions(
query = session.query(AuthSession).filter(AuthSession.account_id == account_id) query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
if tenant_id is not None: if tenant_id is not None:
query = query.filter(AuthSession.tenant_id == tenant_id) query = query.filter(AuthSession.tenant_id == tenant_id)
rows = query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc()).all() if not include_inactive:
summaries = tuple( query = query.filter(
AuthSession.revoked_at.is_(None),
AuthSession.expires_at > effective_at,
)
rows = (
query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc())
.limit(max(1, min(limit, MAX_SESSION_LIST_ITEMS)))
.all()
)
return tuple(
session_summary( session_summary(
item, item,
current_session_id=current_session_id, current_session_id=current_session_id,
@@ -77,9 +86,6 @@ def list_account_sessions(
) )
for item in rows for item in rows
) )
if not include_inactive:
summaries = tuple(item for item in summaries if item.status == "active")
return summaries[: max(1, min(limit, MAX_SESSION_LIST_ITEMS))]
def revoke_account_session( def revoke_account_session(
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import unittest
from datetime import timedelta
from unittest.mock import patch
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from starlette.requests import Request
from govoplan_access.backend.auth.dependencies import _resolve_legacy_principal_context
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
from govoplan_access.backend.auth.tokens import hash_secret
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import Account, AuthSession, Role, ServiceAccount, User, UserRoleAssignment
from govoplan_access.backend.security.api_keys import create_api_key
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.db.base import Base
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
class AuthCacheSecurityTests(unittest.TestCase):
def setUp(self) -> None:
principal_summary_cache.clear()
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
AccessBase.metadata.create_all(self.engine)
self.revision_tables = [ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__]
Base.metadata.create_all(self.engine, tables=self.revision_tables)
self.session = sessionmaker(bind=self.engine)()
self.cache_setting = patch.object(settings, "auth_principal_cache_enabled", True)
self.cache_setting.start()
self.tenant = Tenant(id="cache-tenant", slug="cache-tenant", name="Cache tenant")
self.session.add(self.tenant)
self.session.commit()
def tearDown(self) -> None:
self.cache_setting.stop()
principal_summary_cache.clear()
self.session.close()
AccessBase.metadata.drop_all(self.engine)
scope_registry.metadata.drop_all(self.engine)
Base.metadata.drop_all(self.engine, tables=self.revision_tables)
self.engine.dispose()
def identity(self, *, service: bool = False) -> tuple[Account, User]:
account = Account(id="cache-account", email="cache@example.test", normalized_email="cache@example.test", auth_provider="service_account" if service else "local")
user = User(id="cache-user", tenant_id=self.tenant.id, account_id=account.id, email=account.email, auth_provider=account.auth_provider)
role = Role(id="cache-role", tenant_id=self.tenant.id, slug="reader", name="Reader", permissions=["files:file:read"])
assignment = UserRoleAssignment(tenant_id=self.tenant.id, user_id=user.id, role_id=role.id)
self.session.add_all([account, user, role, assignment])
self.session.commit()
return account, user
def resolve(self, token: str, *, cookie: bool = False, csrf: str | None = None):
headers = []
if cookie:
cookies = f"{settings.auth_session_cookie_name}={token}"
if csrf is not None:
cookies += f"; {settings.auth_csrf_cookie_name}={csrf}"
headers.append((b"x-csrf-token", csrf.encode()))
headers.append((b"cookie", cookies.encode()))
request = Request({"type": "http", "method": "POST", "path": "/protected", "headers": headers})
return _resolve_legacy_principal_context(request, self.session, authorization=None if cookie else f"Bearer {token}", x_api_key=None)
def test_warmed_api_key_is_never_accepted_as_a_session_cookie(self) -> None:
_, user = self.identity()
key = create_api_key(self.session, user=user, name="Test", scopes=["files:file:read"])
self.session.commit()
with self.assertRaises(HTTPException) as cold:
self.resolve(key.secret, cookie=True)
self.assertEqual(401, cold.exception.status_code)
self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method)
with self.assertRaises(HTTPException) as warm:
self.resolve(key.secret, cookie=True)
self.assertEqual(401, warm.exception.status_code)
self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method)
def test_service_account_keeps_current_ceiling_and_provenance_with_cache_enabled(self) -> None:
account, user = self.identity(service=True)
item = ServiceAccount(id="cache-service", tenant_id=self.tenant.id, account_id=account.id, membership_id=user.id, name="Cache worker", normalized_name="cache worker", scope_ceiling=["dataflow:pipeline:run"])
# A credential issued before a ceiling reduction can retain wider stored
# scopes. Ordinary membership roles must not override the current ceiling.
key = create_api_key(self.session, user=user, name="Worker", scopes=["dataflow:pipeline:run", "files:file:read"])
self.session.add(item)
self.session.commit()
for _ in range(2):
context = self.resolve(key.secret)
self.assertEqual(frozenset({"dataflow:pipeline:run"}), context.principal.scopes)
self.assertEqual("service_account", context.principal.auth_method)
self.assertEqual(item.id, context.principal.service_account_id)
self.assertFalse(context.principal.role_ids)
item.scope_ceiling = []
self.session.commit()
self.assertEqual(frozenset(), self.resolve(key.secret).principal.scopes)
item.is_active = False
self.session.commit()
with self.assertRaises(HTTPException) as inactive:
self.resolve(key.secret)
self.assertEqual(401, inactive.exception.status_code)
def test_warmed_session_cookie_still_requires_matching_csrf(self) -> None:
account, user = self.identity()
token, csrf = "ms_cache-session", "cache-csrf"
auth_session = AuthSession(id="cache-session", tenant_id=self.tenant.id, user_id=user.id, account_id=account.id, token_hash=hash_secret(token), csrf_token_hash=hash_secret(csrf), expires_at=utc_now() + timedelta(hours=1))
self.session.add(auth_session)
self.session.commit()
self.resolve(token)
for supplied in (None, "incorrect"):
with self.subTest(csrf=supplied), self.assertRaises(HTTPException) as denied:
self.resolve(token, cookie=True, csrf=supplied)
self.assertEqual(403, denied.exception.status_code)
self.assertEqual("session", self.resolve(token, cookie=True, csrf=csrf).principal.auth_method)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,164 @@
from __future__ import annotations
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from alembic import command
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_access.backend.api.v1.routes import router
from govoplan_access.backend.auth.dependencies import get_api_principal
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment, Role
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.migrations import alembic_config
from govoplan_core.db.session import get_session
from govoplan_core.tenancy.scope import Tenant
TABLE_NAME = "access_external_function_role_assignments"
REPAIR_REVISION = "d8f1b4e7a0c3"
class ExternalFunctionMappingMigrationTests(unittest.TestCase):
def test_release_missing_table_repair(self) -> None:
self._verify_upgrade("release", missing=True)
def test_release_existing_mappings_preserved(self) -> None:
self._verify_upgrade("release", missing=False)
def test_dev_missing_table_repair(self) -> None:
self._verify_upgrade("dev", missing=True)
def test_dev_existing_mappings_preserved(self) -> None:
self._verify_upgrade("dev", missing=False)
def _verify_upgrade(self, track: str, *, missing: bool) -> None:
previous = "c7e0a3d6f9b2" if track == "release" else "b6d9f2a5c8e1"
with tempfile.TemporaryDirectory(prefix="govoplan-function-mapping-upgrade-") as directory:
url = f"sqlite:///{Path(directory) / 'upgrade.db'}"
config = alembic_config(database_url=url, enabled_modules=("access",), migration_track=track)
command.upgrade(config, "4f2a9c8e7b6d")
command.upgrade(config, previous)
engine = create_engine(url, connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def enforce_foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys=ON")
try:
with Session(engine) as session:
session.add_all([
Tenant(id="tenant-1", slug="tenant-1", name="Existing tenant"),
Tenant(id="tenant-2", slug="tenant-2", name="Other tenant"),
])
session.flush()
session.add_all([
Role(id="role-1", tenant_id="tenant-1", slug="role-1", name="Existing role", permissions=["access:function:read"]),
Role(id="role-2", tenant_id="tenant-2", slug="role-2", name="Other role", permissions=["access:role:read"]),
])
session.commit()
if missing:
# Reproduce only in this isolated database: a recorded baseline
# with the exact missing table observed in the live 500 response.
with engine.begin() as connection:
connection.execute(text("DROP TABLE access_external_function_role_assignments"))
else:
self._insert_mapping(engine, "mapping-1", "tenant-1", "role-1")
self._insert_mapping(engine, "mapping-2", "tenant-2", "role-2")
with engine.connect() as connection:
tables_before = set(inspect(connection).get_table_names())
parents_before = self._parent_rows(connection)
mappings_before = [] if missing else self._mapping_rows(connection)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
principal = ApiPrincipal(
principal=PrincipalRef(account_id="reader", membership_id="reader-1", tenant_id="tenant-1", scopes=frozenset({"access:function:read"})),
account=None,
user=None,
)
def test_session():
with Session(engine) as session:
yield session
app.dependency_overrides[get_session] = test_session
app.dependency_overrides[get_api_principal] = lambda: principal
with TestClient(app, raise_server_exceptions=False) as client:
path = "/api/v1/admin/external-function-role-mappings"
if missing:
self.assertEqual(client.get(f"{path}/delta").status_code, 500)
command.upgrade(config, REPAIR_REVISION)
command.upgrade(config, REPAIR_REVISION)
for suffix in ("", "/delta"):
response = client.get(f"{path}{suffix}")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual([item["id"] for item in response.json()["mappings"]], [] if missing else ["mapping-1"])
self.assertEqual(response.json()["total"], 0 if missing else 1)
self.assertEqual(client.get(f"{path}{suffix}?tenant_id=tenant-2").status_code, 409)
principal.principal = PrincipalRef(account_id="reader", membership_id="reader-1", tenant_id="tenant-1", scopes=frozenset())
self.assertEqual(client.get(f"{path}/delta").status_code, 403)
with engine.connect() as connection:
inspector = inspect(connection)
self.assertEqual(set(inspector.get_table_names()), tables_before | {TABLE_NAME})
self.assertEqual(self._parent_rows(connection), parents_before)
self.assertEqual(self._mapping_rows(connection), mappings_before)
columns = inspector.get_columns(TABLE_NAME)
self.assertEqual({item["name"] for item in columns}, {"id", "tenant_id", "source_module", "function_id", "role_id", "settings", "created_at", "updated_at"})
self.assertTrue(all(not item["nullable"] for item in columns))
self.assertEqual(inspector.get_pk_constraint(TABLE_NAME)["constrained_columns"], ["id"])
self.assertIn(["tenant_id", "source_module", "function_id", "role_id"], [item["column_names"] for item in inspector.get_unique_constraints(TABLE_NAME)])
self.assertEqual({tuple(item["column_names"]) for item in inspector.get_indexes(TABLE_NAME)}, {("tenant_id",), ("role_id",), ("function_id",), ("source_module",)})
self.assertEqual({(tuple(item["constrained_columns"]), item["referred_table"], item["options"]["ondelete"]) for item in inspector.get_foreign_keys(TABLE_NAME)}, {(("role_id",), "access_roles", "CASCADE"), (("tenant_id",), "core_scopes", "CASCADE")})
self._insert_mapping(engine, "mapping-after-repair", "tenant-1", "role-1", function_id="new-function")
with self.assertRaises(IntegrityError):
self._insert_mapping(engine, "duplicate", "tenant-1", "role-1", function_id="new-function")
with self.assertRaises(IntegrityError):
self._insert_mapping(engine, "bad-role", "tenant-1", "missing-role")
with self.assertRaises(IntegrityError):
self._insert_mapping(engine, "bad-tenant", "missing-tenant", "role-1")
with engine.connect() as connection:
all_mappings = self._mapping_rows(connection)
command.downgrade(config, previous)
command.upgrade(config, REPAIR_REVISION)
with engine.connect() as connection:
self.assertEqual(self._mapping_rows(connection), all_mappings)
self.assertEqual(self._parent_rows(connection), parents_before)
finally:
engine.dispose()
@staticmethod
def _insert_mapping(engine, mapping_id: str, tenant_id: str, role_id: str, *, function_id: str = "function-1") -> None:
now = datetime.now(timezone.utc)
with Session(engine) as session:
session.add(ExternalFunctionRoleAssignment(
id=mapping_id, tenant_id=tenant_id, role_id=role_id,
source_module="organizations", function_id=function_id,
settings={"meaning": "Existing mapping", "nested": {"retained": True}},
created_at=now, updated_at=now,
))
session.commit()
@staticmethod
def _mapping_rows(connection):
return [dict(row) for row in connection.execute(text("SELECT * FROM access_external_function_role_assignments ORDER BY id")).mappings()]
@staticmethod
def _parent_rows(connection):
return {
"roles": [dict(row) for row in connection.execute(text("SELECT * FROM access_roles ORDER BY id")).mappings()],
"tenants": [dict(row) for row in connection.execute(text("SELECT * FROM core_scopes ORDER BY id")).mappings()],
}
if __name__ == "__main__":
unittest.main()
@@ -6,6 +6,12 @@ from govoplan_access.backend.manifest import manifest
class InterfaceDocumentationContractTests(unittest.TestCase): class InterfaceDocumentationContractTests(unittest.TestCase):
def test_password_change_flag_is_documented_as_unenforced(self) -> None:
topic = next(item for item in manifest.documentation if item.id == "access.reference.authentication-fields")
self.assertIn("currently advisory metadata", topic.body)
self.assertIn("server-side enforcement are not implemented", topic.body)
self.assertIn("serverseitige Durchsetzung sind noch nicht umgesetzt", topic.translations["de"]["body"])
def test_all_static_topics_have_complete_german_content(self) -> None: def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation: for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {}) german = (topic.translations or {}).get("de", {})
+28
View File
@@ -30,6 +30,34 @@ class PermissionCatalogContractTests(unittest.TestCase):
self.assertIn("files:file:read", scopes) self.assertIn("files:file:read", scopes)
self.assertIn("files:read", scopes) self.assertIn("files:read", scopes)
def test_api_key_intersection_excludes_canonical_and_legacy_system_scopes(self) -> None:
for scope in ("access:system_credential:write", "access:tenant:create", "system:tenants:create"):
with self.subTest(scope=scope):
self.assertEqual([], access_catalog.intersect_api_key_scopes([scope], [scope]))
def test_api_key_module_wildcards_expand_only_to_concrete_tenant_scopes(self) -> None:
scopes = access_catalog.intersect_api_key_scopes(["access:*"], ["access:*"])
self.assertIn("access:membership:read", scopes)
self.assertNotIn("access:*", scopes)
self.assertFalse(access_catalog.scopes_grant(scopes, "access:system_credential:write"))
catalog = access_catalog.permission_map()
self.assertTrue(all(catalog[scope].level == "tenant" for scope in scopes if scope in catalog))
def test_api_key_intersection_preserves_unknown_concrete_module_grants(self) -> None:
self.assertEqual(
["optional-module:record:read"],
access_catalog.intersect_api_key_scopes(["optional-module:record:read"], ["optional-module:record:read"]),
)
def test_api_key_intersection_preserves_concrete_tenant_compatibility_aliases(self) -> None:
scopes = access_catalog.intersect_api_key_scopes(["files:read"], ["files:file:read"])
self.assertIn("files:read", scopes)
self.assertIn("files:file:read", scopes)
self.assertTrue(access_catalog.scopes_grant(scopes, "files:file:read"))
def test_api_key_intersection_does_not_retain_unknown_wildcards(self) -> None:
self.assertEqual([], access_catalog.intersect_api_key_scopes(["optional-module:*"], ["optional-module:*"]))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+31 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import unittest import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db.base import AccessBase
@@ -182,6 +182,36 @@ class SessionManagementTests(unittest.TestCase):
self.assertIsNone(hidden) self.assertIsNone(hidden)
self.assertFalse(changed) self.assertFalse(changed)
def test_listing_applies_activity_filter_and_limit_before_loading_history(self) -> None:
statements: list[str] = []
def capture_query(connection, cursor, statement, parameters, context, executemany):
if statement.lstrip().upper().startswith("SELECT") and "access_auth_sessions" in statement:
statements.append(statement)
event.listen(self.engine, "before_cursor_execute", capture_query)
try:
for include_inactive in (False, True):
with self.subTest(include_inactive=include_inactive):
statements.clear()
summaries = list_account_sessions(
self.session,
account_id="account-1",
current_session_id="session-current",
include_inactive=include_inactive,
limit=1,
now=self.now,
)
self.assertEqual(1, len(summaries))
self.assertEqual(1, len(statements))
self.assertIn("LIMIT", statements[0])
if not include_inactive:
self.assertEqual("active", summaries[0].status)
self.assertIn("revoked_at IS NULL", statements[0])
self.assertIn("expires_at >", statements[0])
finally:
event.remove(self.engine, "before_cursor_execute", capture_query)
def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None: def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None:
with self.assertRaisesRegex(ValueError, "current session"): with self.assertRaisesRegex(ValueError, "current session"):
revoke_account_session( revoke_account_session(
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.24", "version": "0.1.25",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -16,7 +16,7 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -1,7 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui"; import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Plus, Search, Trash2 } from "lucide-react"; import { Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin"; import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react"; import { Pencil, Plus, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
import { import {
createExternalFunctionRoleMapping, createExternalFunctionRoleMapping,
deleteExternalFunctionRoleMapping, deleteExternalFunctionRoleMapping,
@@ -10,7 +10,7 @@ import {
type ExternalFunctionRoleMappingItem, type ExternalFunctionRoleMappingItem,
type RoleSummary type RoleSummary
} from "../../api/admin"; } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button, FormGrid } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
+2 -2
View File
@@ -1,7 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui"; import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin"; import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
+2 -2
View File
@@ -1,7 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui"; import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin"; import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -1,7 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui"; import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { import {
createSystemRole, createSystemRole,
deleteSystemRole, deleteSystemRole,
@@ -1,7 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui"; import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Search, Pencil, Plus, Trash2 } from "lucide-react"; import { Search, Pencil, Plus, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings } from "@govoplan/core-webui"; import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
+2 -2
View File
@@ -1,7 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui"; import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin"; import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";