Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cb72ccb0 | ||
|
|
7eb67b9fa7 | ||
|
|
8f8072b4ae |
@@ -146,6 +146,10 @@ cd /mnt/DATA/git/govoplan-core
|
||||
|
||||
## Login Throttling
|
||||
|
||||
Optional PostgreSQL password-recovery race checks and their disposable-database
|
||||
setup are documented in English and German in
|
||||
[docs/PASSWORD_RECOVERY_POSTGRES_TESTS.md](docs/PASSWORD_RECOVERY_POSTGRES_TESTS.md).
|
||||
|
||||
Interactive password login is throttled by normalized global login identity and by
|
||||
the directly connected client address. The deployment defaults are 10 identity
|
||||
failures and 100 client failures in a 15-minute window. Counters use
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Password recovery: PostgreSQL concurrency verification
|
||||
|
||||
## English
|
||||
|
||||
`tests/test_password_recovery_postgres.py` contains four optional real-transaction
|
||||
regressions: simultaneous redemption of one recovery code, simultaneous changes
|
||||
using one old password/session, competing recovery-code issuance, and issuer
|
||||
password revocation during redemption. They reuse the synthetic Access HTTP
|
||||
fixture with real PostgreSQL account locks and compare-and-swap updates.
|
||||
|
||||
Provision a **disposable test database**, preferably in a temporary PostgreSQL
|
||||
cluster with only a private Unix socket. Never use a production or shared
|
||||
development database. The supplied role needs permission to create and drop
|
||||
schemas. The tests create a random `access_password_race_*` schema per case and
|
||||
drop only that schema afterward, including on test failure. Audit emission is
|
||||
mocked as in the ordinary fixture; these are not audit-sink integration tests.
|
||||
|
||||
From the Access checkout, using the workspace Python environment with the current
|
||||
Core/Access sources, pytest, and psycopg installed:
|
||||
|
||||
```sh
|
||||
GOVOPLAN_ACCESS_TEST_POSTGRES_URL='postgresql+psycopg://test_role@/disposable_test?host=/absolute/private/socket&port=55439' \
|
||||
../govoplan/.venv/bin/python -m pytest -q tests/test_password_recovery_postgres.py
|
||||
```
|
||||
|
||||
Replace the synthetic URL with the explicitly provisioned test fixture. Without
|
||||
this variable all four tests are skipped; there is no fallback to application
|
||||
database settings. The tests do not provision or stop PostgreSQL: the fixture
|
||||
owner must stop its temporary cluster and verify cleanup after the run. Existing
|
||||
portable password-recovery tests continue to run without PostgreSQL.
|
||||
|
||||
## Deutsch
|
||||
|
||||
`tests/test_password_recovery_postgres.py` enthält vier optionale Regressionstests
|
||||
mit echten Transaktionen: gleichzeitige Einlösung desselben Wiederherstellungscodes,
|
||||
gleichzeitige Änderungen mit demselben alten Passwort und derselben Sitzung,
|
||||
konkurrierende Code-Ausstellung sowie Passwortwechsel des ausstellenden
|
||||
Administrators während einer Einlösung. Die synthetische Access-HTTP-Testumgebung
|
||||
nutzt dabei echte PostgreSQL-Kontosperren und bedingte Datenbankaktualisierungen.
|
||||
|
||||
Nur eine **wegwerfbare Testdatenbank** verwenden, möglichst in einem temporären
|
||||
PostgreSQL-Cluster mit privatem Unix-Socket. Produktionsdatenbanken und gemeinsam
|
||||
genutzte Entwicklungsdatenbanken sind ausgeschlossen. Die Testrolle benötigt
|
||||
Berechtigungen zum Erstellen und Löschen von Schemas. Jeder Test erstellt ein
|
||||
zufälliges Schema `access_password_race_*` und entfernt ausschließlich dieses
|
||||
Schema auch bei Fehlern. Die Audit-Ausgabe bleibt wie im bestehenden Testaufbau
|
||||
simuliert; ein externer Audit-Dienst wird damit nicht geprüft.
|
||||
|
||||
Der obige Aufruf gilt aus dem Access-Checkout mit der aktuellen
|
||||
Workspace-Python-Umgebung. Die Beispiel-URL muss durch die ausdrücklich
|
||||
bereitgestellte Testumgebung ersetzt werden. Ohne
|
||||
`GOVOPLAN_ACCESS_TEST_POSTGRES_URL` werden alle vier Tests übersprungen; es gibt
|
||||
keinen Rückgriff auf die Datenbankeinstellungen der Anwendung. Die Tests starten
|
||||
und stoppen PostgreSQL nicht selbst: Der Betreiber der Testumgebung muss den
|
||||
temporären Cluster anschließend stoppen und die Bereinigung prüfen. Die
|
||||
bestehenden portablen Tests laufen weiterhin ohne PostgreSQL.
|
||||
@@ -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
|
||||
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 and may revoke only a session belonging to that membership and tenant.
|
||||
The mutation requires both the central membership-update permission and an
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,7 +18,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-access"
|
||||
version = "0.1.24"
|
||||
version = "0.1.25"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.43",
|
||||
"govoplan-core>=0.1.45",
|
||||
"redis>=5,<6",
|
||||
"SQLAlchemy>=2,<3",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.24"
|
||||
__version__ = "0.1.25"
|
||||
|
||||
@@ -539,6 +539,7 @@ def _system_account_response_item(
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
is_active=account.is_active,
|
||||
local_password=account.auth_provider == "local",
|
||||
memberships=[
|
||||
_system_membership_item(
|
||||
user,
|
||||
|
||||
@@ -660,6 +660,7 @@ class SystemAccountItem(BaseModel):
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
is_active: bool
|
||||
local_password: bool = False
|
||||
memberships: list[dict[str, Any]] = Field(default_factory=list)
|
||||
roles: list[RoleSummary] = Field(default_factory=list)
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
@@ -6,7 +6,10 @@ from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
@@ -58,12 +61,19 @@ from govoplan_core.i18n import (
|
||||
user_enabled_language_codes,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
|
||||
from govoplan_access.backend.security.password_change import (
|
||||
MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, RECOVERY_MINUTES,
|
||||
enforce_password_change, issue_recovery, local_password_account, locked_local_account,
|
||||
password_change_required, recovery_issuer_authorized, replace_password,
|
||||
)
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.db.models import PasswordRecovery
|
||||
from govoplan_access.backend.security.login_throttle import (
|
||||
LoginThrottle,
|
||||
LoginThrottleDecision,
|
||||
@@ -89,7 +99,34 @@ from govoplan_access.backend.session_management import (
|
||||
session_summary,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
class AuthRoute(APIRoute):
|
||||
def get_route_handler(self):
|
||||
handler = super().get_route_handler()
|
||||
|
||||
async def without_secret_validation_inputs(request: Request):
|
||||
try:
|
||||
return await handler(request)
|
||||
except RequestValidationError as exc:
|
||||
# FastAPI's default validation response echoes rejected input,
|
||||
# including a password/code whose length validation failed.
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"detail": [
|
||||
{
|
||||
key: error[key]
|
||||
for key in ("type", "loc", "msg")
|
||||
if key in error
|
||||
}
|
||||
for error in exc.errors()
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
return without_secret_validation_inputs
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"], route_class=AuthRoute)
|
||||
|
||||
|
||||
class ActingContextInfo(BaseModel):
|
||||
@@ -131,6 +168,27 @@ class OtherSessionRevocationResponse(BaseModel):
|
||||
revoked_count: int
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: SecretStr = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH)
|
||||
new_password: SecretStr = Field(min_length=MIN_PASSWORD_LENGTH, max_length=MAX_PASSWORD_LENGTH)
|
||||
|
||||
|
||||
class PasswordRecoveryIssueRequest(BaseModel):
|
||||
current_password: SecretStr = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH)
|
||||
identity_verified: Literal[True]
|
||||
|
||||
|
||||
class PasswordRecoveryCompleteRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
recovery_code: SecretStr = Field(min_length=1, max_length=256)
|
||||
new_password: SecretStr = Field(min_length=MIN_PASSWORD_LENGTH, max_length=MAX_PASSWORD_LENGTH)
|
||||
|
||||
|
||||
class PasswordRecoveryIssueResponse(BaseModel):
|
||||
recovery_code: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
def _account_session_info(item: SessionSummary) -> AccountSessionInfo:
|
||||
return AccountSessionInfo(**asdict(item))
|
||||
|
||||
@@ -217,7 +275,7 @@ def _cookie_samesite() -> str:
|
||||
|
||||
|
||||
def _set_auth_cookies(response: Response, created) -> None:
|
||||
max_age = max(0, int((created.model.expires_at - utc_now()).total_seconds()))
|
||||
max_age = max(0, int((ensure_aware_utc(created.model.expires_at) - utc_now()).total_seconds()))
|
||||
common = {
|
||||
"secure": settings.auth_cookie_secure,
|
||||
"samesite": _cookie_samesite(),
|
||||
@@ -283,6 +341,8 @@ def _user_info(
|
||||
tenant_display_name=user.display_name,
|
||||
is_tenant_admin=user.is_tenant_admin,
|
||||
password_reset_required=account.password_reset_required,
|
||||
required_auth_action="change_password" if password_change_required(account) else None,
|
||||
local_password=local_password_account(account),
|
||||
preferred_language=preferred_language,
|
||||
enabled_language_codes=enabled_language_codes or [],
|
||||
ui_preferences=_user_ui_preferences(user.settings),
|
||||
@@ -299,6 +359,8 @@ def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo:
|
||||
tenant_display_name=user.display_name,
|
||||
is_tenant_admin=user.is_tenant_admin,
|
||||
password_reset_required=account.password_reset_required,
|
||||
required_auth_action="change_password" if password_change_required(account) else None,
|
||||
local_password=local_password_account(account),
|
||||
)
|
||||
|
||||
|
||||
@@ -393,11 +455,13 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
|
||||
account = (
|
||||
session.query(Account)
|
||||
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
password_hash = account.password_hash if account is not None and account.password_hash else DUMMY_PASSWORD_HASH
|
||||
password_matches = verify_password(payload.password, password_hash)
|
||||
if account is None or not account.password_hash or not password_matches:
|
||||
if account is None or not local_password_account(account) or not account.password_hash or not password_matches:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
||||
|
||||
query = (
|
||||
@@ -533,7 +597,7 @@ def _session_response(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
||||
def _resolve_auth_context(request: Request, session: Session, *, allow_password_change: bool = False) -> AuthContext:
|
||||
token, source = _extract_auth_token(request)
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||
@@ -549,6 +613,7 @@ def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
||||
tenant=tenant,
|
||||
expected_tenant_id=api_key.tenant_id,
|
||||
)
|
||||
enforce_password_change(account)
|
||||
return AuthContext(
|
||||
account=account,
|
||||
user=user,
|
||||
@@ -570,6 +635,8 @@ def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
||||
tenant=tenant,
|
||||
expected_tenant_id=auth_session.tenant_id,
|
||||
)
|
||||
if not allow_password_change:
|
||||
enforce_password_change(account)
|
||||
return AuthContext(
|
||||
account=account,
|
||||
user=user,
|
||||
@@ -633,7 +700,7 @@ def _shell_response(
|
||||
|
||||
|
||||
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
|
||||
context = _resolve_auth_context(request, session)
|
||||
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||
return _session_response(
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
@@ -645,7 +712,7 @@ def _resolve_lightweight_session(request: Request, session: Session) -> AuthSess
|
||||
|
||||
|
||||
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
|
||||
context = _resolve_auth_context(request, session)
|
||||
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||
if context.api_key is not None:
|
||||
user_scopes = collect_user_scopes(session, context.user, include_system=False)
|
||||
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
|
||||
@@ -660,6 +727,8 @@ def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse
|
||||
)
|
||||
|
||||
scopes = collect_user_scopes(session, context.user, include_system=True)
|
||||
if password_change_required(context.account):
|
||||
scopes = []
|
||||
return _shell_response(
|
||||
session,
|
||||
account=context.account,
|
||||
@@ -852,8 +921,13 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
|
||||
system_roles = authorization_context.system_roles
|
||||
groups = authorization_context.groups
|
||||
effective_scopes = authorization_context.scopes
|
||||
if password_change_required(account):
|
||||
effective_scopes = []
|
||||
tenant_roles = []
|
||||
system_roles = []
|
||||
groups = []
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
|
||||
if maintenance_mode.enabled and not scopes_grant(authorization_context.scopes, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=maintenance_response_detail(maintenance_mode),
|
||||
@@ -895,6 +969,333 @@ def auth_session(request: Request, session: Session = Depends(get_session)):
|
||||
return _resolve_lightweight_session(request, session)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _password_operation_throttle() -> LoginThrottle:
|
||||
# Sensitive re-authorization and public recovery remain bounded even if an
|
||||
# operator disables ordinary login throttling for a test installation.
|
||||
return build_login_throttle(
|
||||
redis_url=settings.redis_url,
|
||||
identity_limit=settings.auth_login_throttle_identity_limit,
|
||||
client_limit=settings.auth_login_throttle_client_limit,
|
||||
window_seconds=settings.auth_login_throttle_window_seconds,
|
||||
redis_retry_seconds=settings.auth_login_throttle_redis_retry_seconds,
|
||||
key_prefix="govoplan:access:password:v1",
|
||||
)
|
||||
|
||||
|
||||
def _consume_password_attempt(request: Request, *, identity: str) -> None:
|
||||
throttle = _password_operation_throttle()
|
||||
context = {
|
||||
"normalized_email": identity,
|
||||
"tenant_slug": None,
|
||||
"client_address": request.client.host if request.client else None,
|
||||
}
|
||||
decision = throttle.check(**context)
|
||||
if decision.allowed:
|
||||
# Reserve before verification, so simultaneous requests cannot all
|
||||
# check an empty bucket and then evade the limit. Count successes too.
|
||||
decision = throttle.record_failure(**context)
|
||||
if not decision.allowed:
|
||||
raise HTTPException(
|
||||
429,
|
||||
detail={
|
||||
"code": "password_rate_limited",
|
||||
"message": "Too many password attempts. Try again later.",
|
||||
},
|
||||
headers={"Retry-After": str(max(1, decision.retry_after_seconds))},
|
||||
)
|
||||
|
||||
|
||||
def _password_context(request: Request, session: Session) -> AuthContext:
|
||||
# Authentication may schedule a last-seen touch. Do not flush that session
|
||||
# row before locking the account: password replacement takes those locks in
|
||||
# account-then-session order and the reverse order could deadlock.
|
||||
with session.no_autoflush:
|
||||
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||
_verify_profile_mutation_allowed(request, context)
|
||||
context.account = locked_local_account(session, context.account.id)
|
||||
# The initial auth read can precede a concurrent password change. Check
|
||||
# credential lifetime and membership again after obtaining the lock.
|
||||
session.refresh(context.auth_session)
|
||||
session.refresh(context.user)
|
||||
session.refresh(context.tenant)
|
||||
current = context.auth_session
|
||||
if (
|
||||
current.revoked_at is not None
|
||||
or ensure_aware_utc(current.expires_at) <= utc_now()
|
||||
):
|
||||
raise HTTPException(401, detail="Invalid session")
|
||||
_active_context_or_401(
|
||||
user=context.user,
|
||||
account=context.account,
|
||||
tenant=context.tenant,
|
||||
expected_tenant_id=current.tenant_id,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def _require_recovery_enabled() -> None:
|
||||
if not settings.auth_local_password_recovery_enabled:
|
||||
raise HTTPException(
|
||||
409,
|
||||
detail={
|
||||
"code": "password_recovery_disabled",
|
||||
"message": "Administrator-assisted local password recovery has not been enabled.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _password_audit(
|
||||
session: Session, *, account: Account, user: User, action: str, details: dict
|
||||
) -> None:
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action=action,
|
||||
object_type="access_account",
|
||||
object_id=account.id,
|
||||
details=details,
|
||||
)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=None,
|
||||
source_module="access",
|
||||
resource_type="access_account",
|
||||
resource_id=account.id,
|
||||
reason=action,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/password/policy")
|
||||
def password_policy():
|
||||
return {
|
||||
"recovery_enabled": settings.auth_local_password_recovery_enabled,
|
||||
"min_length": MIN_PASSWORD_LENGTH,
|
||||
"max_length": MAX_PASSWORD_LENGTH,
|
||||
"recovery_minutes": RECOVERY_MINUTES,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/password/change", response_model=LoginResponse)
|
||||
def change_local_password(
|
||||
payload: PasswordChangeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
context = _password_context(request, session)
|
||||
_consume_password_attempt(
|
||||
request, identity="change:" + context.account.normalized_email
|
||||
)
|
||||
if not verify_password(
|
||||
payload.current_password.get_secret_value(), context.account.password_hash
|
||||
):
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail={
|
||||
"code": "current_password_invalid",
|
||||
"message": "Current password re-authorization failed.",
|
||||
},
|
||||
)
|
||||
counts = replace_password(
|
||||
session,
|
||||
account=context.account,
|
||||
password=payload.new_password.get_secret_value(),
|
||||
)
|
||||
created = create_auth_session(
|
||||
session,
|
||||
user=context.user,
|
||||
hours=settings.auth_session_hours,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
_password_audit(
|
||||
session,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
action="access.password.changed",
|
||||
details={"authorization": "current_password", **counts},
|
||||
)
|
||||
result = LoginResponse(
|
||||
access_token=created.token,
|
||||
expires_at=created.model.expires_at,
|
||||
**_me_response(
|
||||
session,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
tenant=context.tenant,
|
||||
effective_scopes=collect_user_scopes(
|
||||
session, context.user, include_system=True
|
||||
),
|
||||
auth_method="session",
|
||||
session_id=created.model.id,
|
||||
).model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
principal_summary_cache.clear()
|
||||
_set_auth_cookies(response, created)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/password/recovery/{account_id}", response_model=PasswordRecoveryIssueResponse
|
||||
)
|
||||
def issue_password_recovery(
|
||||
account_id: str,
|
||||
payload: PasswordRecoveryIssueRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_recovery_enabled()
|
||||
context = _password_context(request, session)
|
||||
enforce_password_change(context.account)
|
||||
if not recovery_issuer_authorized(
|
||||
session, account_id=context.account.id, membership_id=context.user.id
|
||||
):
|
||||
raise HTTPException(
|
||||
403, detail={"code": "recovery_issuer_required", "message": "Only a current System owner can issue password recovery codes."}
|
||||
)
|
||||
_consume_password_attempt(
|
||||
request, identity="issue:" + context.account.normalized_email
|
||||
)
|
||||
if not verify_password(
|
||||
payload.current_password.get_secret_value(), context.account.password_hash
|
||||
):
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail={
|
||||
"code": "current_password_invalid",
|
||||
"message": "Current password re-authorization failed.",
|
||||
},
|
||||
)
|
||||
# Global account takeover must not be granted by tenant or ordinary account
|
||||
# editor permissions. Ownership is checked from current roles above.
|
||||
target = locked_local_account(session, account_id)
|
||||
membership = (
|
||||
session.query(User)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(
|
||||
User.account_id == target.id,
|
||||
User.is_active.is_(True),
|
||||
Tenant.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if membership is None:
|
||||
raise HTTPException(
|
||||
409, detail={"code": "recovery_membership_required", "message": "The account needs an active tenant membership before recovery."}
|
||||
)
|
||||
code, recovery = issue_recovery(
|
||||
session, account=target, issuer=context.account, membership=context.user
|
||||
)
|
||||
_password_audit(
|
||||
session,
|
||||
account=target,
|
||||
user=context.user,
|
||||
action="access.password.recovery_issued",
|
||||
details={
|
||||
"identity_verified": True,
|
||||
"expires_at": recovery.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return PasswordRecoveryIssueResponse(
|
||||
recovery_code=code, expires_at=recovery.expires_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password/recover")
|
||||
def complete_password_recovery(
|
||||
payload: PasswordRecoveryCompleteRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_recovery_enabled()
|
||||
normalized = normalize_email(payload.email)
|
||||
_consume_password_attempt(request, identity="recover:" + normalized)
|
||||
invalid = HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"code": "recovery_invalid",
|
||||
"message": "The recovery code is invalid, expired, or no longer authorized.",
|
||||
},
|
||||
)
|
||||
recovery = (
|
||||
session.query(PasswordRecovery)
|
||||
.filter(
|
||||
PasswordRecovery.code_hash
|
||||
== hash_secret(payload.recovery_code.get_secret_value())
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if recovery is None:
|
||||
raise invalid
|
||||
try:
|
||||
account = locked_local_account(session, recovery.account_id)
|
||||
except HTTPException:
|
||||
raise invalid from None
|
||||
session.refresh(recovery)
|
||||
if (
|
||||
account.normalized_email != normalized
|
||||
or recovery.consumed_at is not None
|
||||
or ensure_aware_utc(recovery.expires_at) <= utc_now()
|
||||
or not recovery_issuer_authorized(
|
||||
session,
|
||||
account_id=recovery.issuer_account_id,
|
||||
membership_id=recovery.issuer_membership_id,
|
||||
)
|
||||
):
|
||||
raise invalid
|
||||
user = (
|
||||
session.query(User)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(
|
||||
User.account_id == account.id,
|
||||
User.is_active.is_(True),
|
||||
Tenant.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if user is None:
|
||||
raise invalid
|
||||
# CAS supplements the account lock on databases where FOR UPDATE is absent.
|
||||
consumed = (
|
||||
session.query(PasswordRecovery)
|
||||
.filter(
|
||||
PasswordRecovery.id == recovery.id,
|
||||
PasswordRecovery.consumed_at.is_(None),
|
||||
PasswordRecovery.expires_at > utc_now(),
|
||||
)
|
||||
.update({PasswordRecovery.consumed_at: utc_now()}, synchronize_session="fetch")
|
||||
)
|
||||
if consumed != 1:
|
||||
raise invalid
|
||||
counts = replace_password(
|
||||
session, account=account, password=payload.new_password.get_secret_value()
|
||||
)
|
||||
_password_audit(
|
||||
session,
|
||||
account=account,
|
||||
user=user,
|
||||
action="access.password.recovered",
|
||||
details={
|
||||
"authorization": "administrator_code",
|
||||
"issuer_account_id": recovery.issuer_account_id,
|
||||
**counts,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
principal_summary_cache.clear()
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
# Recovery authorizes password replacement only, never a browser session.
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/shell", response_model=AuthShellResponse)
|
||||
def auth_shell(request: Request, session: Session = Depends(get_session)):
|
||||
return _resolve_shell_auth(request, session)
|
||||
@@ -1268,12 +1669,14 @@ def switch_acting_context(
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
response: Response,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
if principal.auth_session is not None:
|
||||
principal.auth_session.revoked_at = utc_now()
|
||||
session.add(principal.auth_session)
|
||||
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||
if context.auth_session is not None:
|
||||
_verify_profile_mutation_allowed(request, context)
|
||||
context.auth_session.revoked_at = utc_now()
|
||||
session.add(context.auth_session)
|
||||
session.commit()
|
||||
_clear_auth_cookies(response)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -44,6 +44,7 @@ from govoplan_access.backend.semantic import collect_external_function_roles, id
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.password_change import enforce_password_change, password_change_required
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_authorization_context,
|
||||
@@ -159,6 +160,7 @@ def _api_principal_from_ref(
|
||||
|
||||
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
||||
enforce_password_change(account)
|
||||
return ApiPrincipal(
|
||||
principal=principal,
|
||||
account=account,
|
||||
@@ -226,6 +228,7 @@ def _resolve_legacy_principal_context(
|
||||
source=source,
|
||||
)
|
||||
if cached is not None:
|
||||
enforce_password_change(cached.account)
|
||||
return cached
|
||||
|
||||
if source != "cookie":
|
||||
@@ -237,6 +240,7 @@ def _resolve_legacy_principal_context(
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
enforce_password_change(context.account)
|
||||
return _cache_resolved_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
@@ -256,6 +260,7 @@ def _resolve_legacy_principal_context(
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
enforce_password_change(context.account)
|
||||
return _cache_resolved_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
@@ -311,6 +316,12 @@ def _rehydrate_cached_principal(
|
||||
source: str,
|
||||
principal: PrincipalRef,
|
||||
) -> 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)
|
||||
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
|
||||
@@ -327,6 +338,10 @@ def _rehydrate_cached_principal(
|
||||
return None
|
||||
|
||||
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
|
||||
if (
|
||||
api_key is None
|
||||
@@ -408,7 +423,10 @@ def _cache_resolved_principal_context(
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> 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
|
||||
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||
refreshed = _refresh_principal_context(
|
||||
@@ -991,6 +1009,12 @@ def _resolve_delegated_user_automation(
|
||||
"belongs to the tenant."
|
||||
),
|
||||
)
|
||||
if password_change_required(account):
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="password_change_required",
|
||||
reason="The automation owner must complete the required local password change.",
|
||||
)
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
|
||||
@@ -51,6 +51,26 @@ class Account(AccessBase, TimestampMixin):
|
||||
)
|
||||
|
||||
|
||||
class PasswordRecovery(AccessBase, TimestampMixin):
|
||||
"""Bounded, administrator-issued authorization; never retain the code."""
|
||||
|
||||
__tablename__ = "access_password_recoveries"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
account_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
issuer_account_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
issuer_membership_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
code_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Identity(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_identities"
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
PublicFrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
@@ -883,7 +884,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
title="Authentication and password fields",
|
||||
summary="Understand which credentials are used for interactive sign-in, initial account enrollment, administrative re-authorization, and automation.",
|
||||
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. Local users can change their own password with their current password in account settings. AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED defaults to false: while disabled, the password-change-required flag remains advisory metadata and administrator-assisted recovery is unavailable. After the complete recovery policy is adopted and this setting is enabled, a flagged local account can sign in only to change its password or sign out; normal authenticated routes and human API keys are blocked until the change is completed. Initial passwords are not given an expiry or a single-use lifetime by the flag. Password replacement rotates the current browser session and CSRF token, signs out every other browser session across tenants, and revokes all human API keys owned by the account. External-provider passwords must be changed or recovered at that provider; service accounts have no local interactive password. Current-password prompts re-authorize the current person's 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. Generated passwords are not applied until Use password is selected."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -900,7 +901,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"title": "Authentifizierungs- und Passwortfelder",
|
||||
"summary": "Einordnen, welche Zugangsdaten für die interaktive Anmeldung, die erste Kontoeinrichtung, die erneute administrative Autorisierung und Automatisierung verwendet werden.",
|
||||
"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. Lokale Benutzer können ihr Passwort in den Kontoeinstellungen mit dem aktuellen Passwort ändern. AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED ist standardmäßig false: Solange die Einstellung deaktiviert ist, bleibt das Kennzeichen für den Passwortwechsel ein unverbindlicher Hinweis und die administrativ unterstützte Wiederherstellung ist nicht verfügbar. Erst nach Einführung der vollständigen Wiederherstellungsrichtlinie und Aktivierung dieser Einstellung kann sich ein gekennzeichnetes lokales Konto ausschließlich zum Passwortwechsel oder Abmelden anmelden; normale authentifizierte Routen und persönliche API-Schlüssel sind bis zum Wechsel gesperrt. Das Kennzeichen verleiht Anfangspasswörtern weder eine Ablaufzeit noch eine einmalige Nutzungsdauer. Der Passwortwechsel erneuert die aktuelle Browsersitzung und das CSRF-Token, meldet alle anderen Sitzungen mandantenübergreifend ab und widerruft alle persönlichen API-Schlüssel des Kontos. Passwörter externer Anbieter müssen dort geändert oder wiederhergestellt werden; Dienstkonten haben kein lokales interaktives Passwort. Die Abfrage des aktuellen Passworts autorisiert die Aktion der aktuellen Person 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. Generierte Passwörter werden erst mit „Passwort verwenden“ übernommen."
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -916,11 +917,101 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.local-password-recovery",
|
||||
title="Change or recover a local password",
|
||||
summary="Use current-password authorization or an explicitly enabled, bounded System-owner recovery handoff.",
|
||||
body=(
|
||||
"For a normal or required change, open the Password section in account settings, enter your current or initial password, and choose a different password of 10–1024 characters. The same action is shown immediately after initial sign-in when enforcement is enabled. Your current browser receives a new session and CSRF cookie; every previous session and human API key for your global account is revoked across all tenants. Update affected automation with newly authorized credentials; dedicated service-account credentials are unaffected. Passwords and recovery codes must not be placed in tickets, URLs, browser storage, or audit notes. "
|
||||
"When a forced change is pending, delegated automation owned by that local account also receives a password_change_required denial until the account completes the change. "
|
||||
"If the password is lost, contact a System owner through your organization's approved identity-verification channel. When administrator-assisted recovery is enabled, the owner opens the central account's recovery action, verifies the person's identity outside GovOPlaN, confirms that verification, and re-authorizes with the owner's own current local password. The server displays a random recovery code once, valid for 15 minutes. The owner hands it to the verified person through an approved channel; GovOPlaN does not send email. A new code supersedes every older code for that account without changing its password. At /password-recovery, enter the account email, the code, and a new password; then sign in normally. Redemption is single-use, revokes all previous sessions and human API keys, and also invalidates unused recovery codes. Both normal password changes and recovery invalidate outstanding codes for the changed account and codes issued by that account for other people. Those people must obtain newly authorized codes, even after a routine System-owner password change. "
|
||||
"Before enabling AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED=true, deploy matching Access/Core WebUI assets, apply the Access e9a2c5f8b1d4 migration through the normal upgrade process, document who verifies identity and how codes are handed over, ensure an available local System owner, and verify first-login and recovery in a test environment. The default false preserves the existing advisory flag behavior; do not claim forced password change is enabled until this setting is active. Recovery issuance requires current system:* authority and an interactive local session; ordinary account editors, tenant administrators, and API keys cannot authorize account takeover. Redemption rechecks the target's local provider, active account and active membership/tenant, and the issuer's current active membership and System-owner authority. Disabled or externally managed accounts are not reactivated or converted by recovery. If every System owner loses access, escalate to the installation's operator recovery process; first-admin enrollment is not a reset mechanism for a populated installation. "
|
||||
"Password operations reserve attempts before verification using the login identity/client limits and window in an independent throttle namespace, even when ordinary login throttling is disabled. Redis shares limits across workers; a bounded process-local fallback applies during outages and cannot provide cluster-wide limits. A 429 response includes Retry-After. Current-password changes and recovery write secret-free audit evidence and revoke credentials in the same transaction; the database stores only password hashes and recovery-code hashes. The recovery table retains consumed/expired hash records until the corresponding account is removed; include it in the installation's retention review. Downgrading the migration removes recovery authorizations, so disable the feature first."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "access_admin", "operator"),
|
||||
order=30,
|
||||
conditions=(DocumentationCondition(required_modules=("access",)),),
|
||||
links=(DocumentationLink(label="Password recovery", href="/password-recovery", kind="runtime"), DocumentationLink(label="Account settings", href="/settings", kind="runtime")),
|
||||
translations={"de": {
|
||||
"title": "Lokales Passwort ändern oder wiederherstellen",
|
||||
"summary": "Das aktuelle Passwort oder eine ausdrücklich aktivierte, zeitlich begrenzte Wiederherstellung durch einen Systemverantwortlichen verwenden.",
|
||||
"body": (
|
||||
"Für einen freiwilligen oder erforderlichen Wechsel öffnen Sie den Abschnitt Passwort in den Kontoeinstellungen, geben das aktuelle beziehungsweise anfängliche Passwort ein und wählen ein anderes Passwort mit 10–1024 Zeichen. Bei aktivierter Durchsetzung erscheint dieselbe Aktion unmittelbar nach der ersten Anmeldung. Der aktuelle Browser erhält eine neue Sitzung und ein neues CSRF-Cookie; alle bisherigen Sitzungen und persönlichen API-Schlüssel des globalen Kontos werden mandantenübergreifend widerrufen. Betroffene Automatisierungen benötigen neu autorisierte Zugangsdaten; Zugangsdaten eigenständiger Dienstkonten bleiben unberührt. Passwörter und Wiederherstellungscodes gehören nicht in Tickets, URLs, Browserspeicher oder Auditnotizen. "
|
||||
"Solange ein erzwungener Wechsel aussteht, erhält auch delegierte Automatisierung dieses lokalen Kontos die Ablehnung password_change_required, bis das Konto den Wechsel abgeschlossen hat. "
|
||||
"Bei verlorenem Passwort wenden Sie sich über den freigegebenen Identitätsprüfungsweg Ihrer Organisation an einen Systemverantwortlichen. Ist die administrativ unterstützte Wiederherstellung aktiviert, öffnet dieser die Wiederherstellungsaktion des zentralen Kontos, prüft die Identität außerhalb von GovOPlaN, bestätigt diese Prüfung und autorisiert sich erneut mit seinem eigenen aktuellen lokalen Passwort. Der Server zeigt einen zufälligen, 15 Minuten gültigen Wiederherstellungscode einmal an. Der Systemverantwortliche übergibt ihn der geprüften Person über einen freigegebenen Kanal; GovOPlaN versendet keine E-Mail. Ein neuer Code ersetzt alle älteren Codes für das Konto, ohne dessen Passwort zu ändern. Unter /password-recovery geben Sie Konto-E-Mail, Code und neues Passwort ein und melden sich anschließend normal an. Die einmalige Einlösung widerruft alle bisherigen Sitzungen und persönlichen API-Schlüssel sowie ungenutzte Wiederherstellungscodes. Sowohl regulärer Passwortwechsel als auch Wiederherstellung machen ausstehende Codes für das geänderte Konto und von diesem Konto für andere Personen ausgestellte Codes ungültig. Diese Personen benötigen neu autorisierte Codes, auch nach einem routinemäßigen Passwortwechsel eines Systemverantwortlichen. "
|
||||
"Vor AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED=true müssen passende Access/Core-WebUI-Dateien bereitgestellt, die Access-Migration e9a2c5f8b1d4 über den normalen Upgradeprozess angewandt, Zuständigkeit und Übergabekanal für Identitätsprüfung dokumentiert, ein erreichbarer lokaler Systemverantwortlicher sichergestellt und erste Anmeldung sowie Wiederherstellung in einer Testumgebung geprüft werden. Der Standard false erhält das bisherige unverbindliche Kennzeichen; behaupten Sie keine Durchsetzung, bevor die Einstellung aktiv ist. Die Codeausgabe verlangt aktuelle system:*-Berechtigung und eine interaktive lokale Sitzung; normale Kontobearbeiter, Mandantenadministratoren und API-Schlüssel dürfen keine Kontoübernahme autorisieren. Bei Einlösung werden lokaler Anbieter, aktives Zielkonto, aktive Mitgliedschaft und aktiver Mandant sowie die aktuelle aktive Mitgliedschaft und Systemverantwortung des Ausstellers erneut geprüft. Deaktivierte oder extern verwaltete Konten werden nicht reaktiviert oder umgewandelt. Verlieren sämtliche Systemverantwortlichen den Zugang, ist das Wiederherstellungsverfahren des Installationsbetreibers einzuschalten; die Ersteinrichtung ist kein Rücksetzverfahren für bestehende Installationen. "
|
||||
"Passwortaktionen reservieren Versuche vor der Prüfung mit den Identitäts-/Clientgrenzen und dem Zeitfenster der Anmeldung in einem unabhängigen Drosselungsbereich, auch bei deaktivierter normaler Anmeldedrosselung. Redis teilt Grenzen zwischen Prozessen; bei Ausfällen gilt ein begrenzter lokaler Ersatz ohne clusterweite Garantie. HTTP 429 enthält Retry-After. Passwortwechsel und Wiederherstellung schreiben Auditnachweise ohne Geheimnisse und widerrufen Zugangsdaten in derselben Transaktion; gespeichert werden ausschließlich Passwort- und Code-Hashes. Verbrauchte oder abgelaufene Hashdatensätze verbleiben bis zur Entfernung des jeweiligen Kontos in der Wiederherstellungstabelle; berücksichtigen Sie diese bei der Aufbewahrungsprüfung. Ein Downgrade der Migration entfernt Wiederherstellungsberechtigungen; deaktivieren Sie die Funktion vorher."
|
||||
),
|
||||
}},
|
||||
metadata={"kind": "operator_workflow"},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.help.password-change",
|
||||
title="Change your local password",
|
||||
summary="Authorize your own password change and understand credential rotation.",
|
||||
body=(
|
||||
"Use your own current or initial password, not another person's password. Choose a different new password of 10–1024 Unicode characters and confirm it exactly. This requires an interactive local-account session; external accounts use their identity provider. Self-service remains available when recovery is disabled. "
|
||||
"A successful change rotates this browser's session and CSRF cookie, ends other sessions across tenants, revokes all account API keys, and invalidates unused recovery codes for this account and codes it issued for others. Update affected integrations. Required-change accounts cannot enter the workspace until this succeeds; signing out leaves the requirement unchanged. Reload only refreshes policy information. Never put passwords or codes in URLs, tickets, browser storage, or logs."
|
||||
),
|
||||
layer="always", documentation_types=("admin", "user"),
|
||||
audience=("user", "access_admin", "operator"), order=31,
|
||||
translations={"de": {
|
||||
"title": "Eigenes lokales Passwort ändern",
|
||||
"summary": "Die eigene Passwortänderung autorisieren und den Austausch der Zugangsdaten verstehen.",
|
||||
"body": (
|
||||
"Geben Sie Ihr eigenes aktuelles oder initiales Passwort ein, niemals das Passwort einer anderen Person. Wählen Sie ein anderes neues Passwort mit 10–1024 Unicode-Zeichen und bestätigen Sie es exakt. Dies benötigt eine interaktive Sitzung eines lokalen Kontos; externe Konten verwenden ihren Identitätsanbieter. Der freiwillige Wechsel bleibt bei deaktivierter Wiederherstellung verfügbar. "
|
||||
"Der erfolgreiche Wechsel erneuert Sitzung und CSRF-Cookie dieses Browsers, beendet andere Sitzungen mandantenübergreifend, widerruft alle Konto-API-Schlüssel und macht ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes ungültig. Aktualisieren Sie betroffene Integrationen. Bei erforderlichem Wechsel bleibt der Arbeitsbereich bis zum Erfolg gesperrt; Abmelden hebt die Anforderung nicht auf. Neuladen aktualisiert nur die Richtlinieninformation. Passwörter und Codes gehören niemals in URLs, Tickets, Browserspeicher oder Protokolle."
|
||||
),
|
||||
}},
|
||||
metadata={"kind": "reference", "help_contexts": ["access.password.change"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.help.password-recovery",
|
||||
title="Recover a local password",
|
||||
summary="Use an authorized one-time code, then sign in normally.",
|
||||
body=(
|
||||
"Recovery is disabled by default. When enabled, contact a System owner through your organization's independent identity-verification process; GovOPlaN sends no recovery email. Enter the target account email, the confidential code, and matching new passwords of 10–1024 Unicode characters. The code expires after 15 minutes and works once; invalid, expired, used, or no-longer-authorized codes require a new handoff. "
|
||||
"Successful recovery ends all target-account sessions, revokes its API keys, and invalidates unused codes for it and codes it issued for others. It does not sign you in: Return to sign in and use the new password. External and inactive accounts are not converted or reactivated. Reload only refreshes policy information. Never share credentials through URLs, tickets, browser storage, or logs."
|
||||
),
|
||||
layer="always", documentation_types=("admin", "user"),
|
||||
audience=("user", "access_admin", "operator"), order=32,
|
||||
translations={"de": {
|
||||
"title": "Lokales Passwort wiederherstellen",
|
||||
"summary": "Einen autorisierten Einmalcode verwenden und sich anschließend normal anmelden.",
|
||||
"body": (
|
||||
"Die Wiederherstellung ist standardmäßig deaktiviert. Ist sie aktiviert, wenden Sie sich über das unabhängige Identitätsprüfungsverfahren Ihrer Organisation an einen Systemverantwortlichen; GovOPlaN versendet keine Wiederherstellungs-E-Mail. Geben Sie Konto-E-Mail, vertraulichen Code und übereinstimmende neue Passwörter mit 10–1024 Unicode-Zeichen ein. Der Code gilt 15 Minuten und funktioniert einmal; ungültige, abgelaufene, verbrauchte oder nicht mehr autorisierte Codes benötigen eine neue Übergabe. "
|
||||
"Der Erfolg beendet alle Sitzungen des Zielkontos, widerruft dessen API-Schlüssel und macht ungenutzte Codes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes ungültig. Es erfolgt keine automatische Anmeldung: Kehren Sie zur Anmeldung zurück und verwenden Sie das neue Passwort. Externe und inaktive Konten werden weder umgewandelt noch reaktiviert. Neuladen aktualisiert nur die Richtlinieninformation. Zugangsdaten gehören niemals in URLs, Tickets, Browserspeicher oder Protokolle."
|
||||
),
|
||||
}},
|
||||
metadata={"kind": "reference", "help_contexts": ["access.password.recover"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.help.password-issue-recovery",
|
||||
title="Issue and hand over a recovery code",
|
||||
summary="A current local System owner verifies identity and authorizes a bounded recovery.",
|
||||
body=(
|
||||
"Only a current System owner with system:* and an interactive local session may issue a code when recovery is enabled. Confirm the selected person's identity independently outside GovOPlaN, then enter your own current password, never the target person's password. The target must be active and local with an active tenant membership. "
|
||||
"Issuing replaces earlier unused target codes without changing the password yet. The code is displayed once, expires after 15 minutes, and can be used once. Deliver it only through an agreed confidential channel. Closing clears the display; passwords and codes must not enter URLs, tickets, browser storage, or logs. Redemption replaces the password, ends target sessions, revokes its API keys, and invalidates codes it issued for others. Authority is rechecked at redemption."
|
||||
),
|
||||
layer="always", documentation_types=("admin", "user"),
|
||||
audience=("user", "access_admin", "operator"), order=33,
|
||||
translations={"de": {
|
||||
"title": "Wiederherstellungscode ausstellen und übergeben",
|
||||
"summary": "Ein aktueller lokaler Systemverantwortlicher prüft die Identität und autorisiert eine begrenzte Wiederherstellung.",
|
||||
"body": (
|
||||
"Nur ein aktueller Systemverantwortlicher mit system:* und interaktiver lokaler Sitzung darf bei aktivierter Wiederherstellung einen Code ausstellen. Prüfen Sie die Identität der ausgewählten Person unabhängig außerhalb von GovOPlaN und geben Sie Ihr eigenes aktuelles Passwort ein, niemals das Passwort der Zielperson. Das Zielkonto muss aktiv und lokal sein und eine aktive Mandantenmitgliedschaft besitzen. "
|
||||
"Die Ausstellung ersetzt frühere ungenutzte Zielcodes, ändert aber noch kein Passwort. Der Code wird einmal angezeigt, gilt 15 Minuten und ist einmal verwendbar. Übergeben Sie ihn ausschließlich über einen vereinbarten vertraulichen Kanal. Schließen leert die Anzeige; Passwörter und Codes gehören nicht in URLs, Tickets, Browserspeicher oder Protokolle. Die Einlösung ersetzt das Passwort, beendet Zielsitzungen, widerruft dessen API-Schlüssel und macht von ihm für andere Personen ausgestellte Codes ungültig. Die Berechtigung wird bei Einlösung erneut geprüft."
|
||||
),
|
||||
}},
|
||||
metadata={"kind": "reference", "help_contexts": ["access.password.issue-recovery"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.grant-user-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.",
|
||||
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",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin"),
|
||||
@@ -961,6 +1052,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"nur die benötigten Rollen vergeben."
|
||||
),
|
||||
"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 "
|
||||
"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."
|
||||
@@ -1220,6 +1312,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. "
|
||||
"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."
|
||||
" 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",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -1252,7 +1345,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"de": {
|
||||
"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.",
|
||||
"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={
|
||||
@@ -1303,6 +1396,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
summary="Reusable credential envelopes keep secrets write-only while administrators constrain which scopes, modules, and servers may use them.",
|
||||
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. "
|
||||
"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."
|
||||
),
|
||||
layer="configured",
|
||||
@@ -1340,7 +1434,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"de": {
|
||||
"title": "Wiederverwendbare Zugangsdaten sicher verwalten",
|
||||
"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={
|
||||
@@ -1401,6 +1495,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. "
|
||||
"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."
|
||||
" 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",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -1436,7 +1531,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"de": {
|
||||
"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.",
|
||||
"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={
|
||||
@@ -1497,6 +1592,10 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
summary="Users can reorder or hide available navigation entries without changing access or other users' workspaces.",
|
||||
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."
|
||||
" 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."
|
||||
" No setup is required for the standard groups: Work; Services and cases; Records and documents; Communication; Meetings and decisions; Data and assurance; People and responsibility. Only available, authorized groups appear. The editor identifies an inherited, custom grouped, or explicitly flat layout. The baseline is personal over tenant over system over these live standard groups. Use inherited layout and Save removes only the personal override, not tenant or system configuration. An intentionally flat layout is preserved until changed; locked entries still cannot be hidden."
|
||||
" 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",
|
||||
documentation_types=("user", "admin"),
|
||||
@@ -1523,6 +1622,10 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"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 "
|
||||
"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."
|
||||
" Die Standardgruppen benötigen keine Einrichtung: Arbeit; Leistungen und Vorgänge; Akten und Dokumente; Kommunikation; Termine und Entscheidungen; Daten und Qualitätssicherung; Personen und Verantwortung. Nur verfügbare, berechtigte Gruppen erscheinen. Der Editor kennzeichnet geerbte, eigene gruppierte und ausdrücklich ungegliederte Anordnungen. Persönliche Vorgaben haben Vorrang vor Mandant, System und diesen aktuellen Standardgruppen. Geerbte Anordnung verwenden und Speichern entfernt nur die persönliche Anpassung, nicht die Mandanten- oder Systemkonfiguration. Eine bewusst ungegliederte Anordnung bleibt bis zu ihrer Änderung erhalten; gesperrte Einträge können weiterhin nicht ausgeblendet werden."
|
||||
" 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 +1635,34 @@ 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.",
|
||||
},
|
||||
),
|
||||
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="The documentation book sits immediately to the right of each Access administration heading; "
|
||||
"field help remains beside its label. "
|
||||
"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": "Das Dokumentationsbuch steht unmittelbar rechts neben der jeweiligen Überschrift der "
|
||||
"Zugriffsverwaltung; Feldhilfe bleibt neben der Feldbezeichnung. "
|
||||
"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(
|
||||
id="access.workflow.manage-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.",
|
||||
body=(
|
||||
"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. "
|
||||
"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. "
|
||||
"Audit evidence records stable actors, targets, and counts without tokens, hashes, cookies, IP addresses, or client strings."
|
||||
),
|
||||
@@ -1572,7 +1696,9 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"summary": "Aktive Browsersitzungen prüfen und einzelne oder alle anderen Sitzungen widerrufen, ohne Zugangsdaten oder Netzwerkkennungen offenzulegen.",
|
||||
"body": (
|
||||
"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. "
|
||||
"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. "
|
||||
"Auditnachweise speichern stabile Akteure, Ziele und Anzahlen, aber keine Token, Hashes, Cookies, IP-Adressen oder Clienttexte."
|
||||
),
|
||||
@@ -1614,7 +1740,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. "
|
||||
"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. "
|
||||
"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",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -1679,7 +1809,11 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"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 "
|
||||
"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."
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -2029,10 +2163,21 @@ def _people_search(context: ModuleContext) -> object:
|
||||
return people_search_capability(context)
|
||||
|
||||
|
||||
def _public_auth_tenant_resolver(_request: object, _session: object) -> str | None:
|
||||
"""Authentication is global: unverified email/code never select a tenant.
|
||||
|
||||
None intentionally denotes no public tenant admission. The password
|
||||
recovery handler first verifies its bounded account authorization, then
|
||||
requires a current active membership in an active tenant before mutation.
|
||||
This resolver grants neither a tenant principal nor module permissions.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.24",
|
||||
version="0.1.25",
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||
@@ -2049,6 +2194,7 @@ manifest = ModuleManifest(
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
public_tenant_resolver=_public_auth_tenant_resolver,
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="access",
|
||||
metadata=AccessBase.metadata,
|
||||
@@ -2057,6 +2203,7 @@ manifest = ModuleManifest(
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
access_models.Account,
|
||||
access_models.PasswordRecovery,
|
||||
access_models.Identity,
|
||||
access_models.IdentityAccountLink,
|
||||
access_models.User,
|
||||
@@ -2090,6 +2237,7 @@ manifest = ModuleManifest(
|
||||
frontend=FrontendModule(
|
||||
module_id="access",
|
||||
package_name="@govoplan/access-webui",
|
||||
public_routes=(PublicFrontendRoute(path="/password-recovery", component="PasswordRecoveryPage", order=20),),
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/admin",
|
||||
|
||||
+62
@@ -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,53 @@
|
||||
"""Add bounded administrator-assisted local password recovery.
|
||||
|
||||
Revision ID: e9a2c5f8b1d4
|
||||
Revises: d8f1b4e7a0c3
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "e9a2c5f8b1d4"
|
||||
down_revision = "d8f1b4e7a0c3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"access_password_recoveries",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("account_id", sa.String(36), nullable=False),
|
||||
sa.Column("issuer_account_id", sa.String(36), nullable=False),
|
||||
sa.Column("issuer_membership_id", sa.String(36), nullable=False),
|
||||
sa.Column("code_hash", sa.String(64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("code_hash"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["issuer_account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["issuer_membership_id"], ["access_users.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_access_password_recoveries_account_id",
|
||||
"access_password_recoveries",
|
||||
["account_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_access_password_recoveries_issuer_account_id",
|
||||
"access_password_recoveries",
|
||||
["issuer_account_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("access_password_recoveries")
|
||||
+62
@@ -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
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add bounded administrator-assisted local password recovery.
|
||||
|
||||
Revision ID: e9a2c5f8b1d4
|
||||
Revises: d8f1b4e7a0c3
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "e9a2c5f8b1d4"
|
||||
down_revision = "d8f1b4e7a0c3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"access_password_recoveries",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("account_id", sa.String(36), nullable=False),
|
||||
sa.Column("issuer_account_id", sa.String(36), nullable=False),
|
||||
sa.Column("issuer_membership_id", sa.String(36), nullable=False),
|
||||
sa.Column("code_hash", sa.String(64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("code_hash"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["issuer_account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["issuer_membership_id"], ["access_users.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_access_password_recoveries_account_id",
|
||||
"access_password_recoveries",
|
||||
["account_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_access_password_recoveries_issuer_account_id",
|
||||
"access_password_recoveries",
|
||||
["issuer_account_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("access_password_recoveries")
|
||||
@@ -212,11 +212,29 @@ def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[st
|
||||
allowed.update(
|
||||
scope
|
||||
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)
|
||||
|
||||
|
||||
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, ...]:
|
||||
registry = _registry()
|
||||
if registry is not None and hasattr(registry, "permissions"):
|
||||
|
||||
@@ -343,6 +343,7 @@ def build_login_throttle(
|
||||
client_limit: int,
|
||||
window_seconds: int,
|
||||
redis_retry_seconds: int,
|
||||
key_prefix: str = "govoplan:access:login:v1",
|
||||
) -> LoginThrottle:
|
||||
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
|
||||
resilient_store = ResilientLoginAttemptStore(
|
||||
@@ -355,4 +356,5 @@ def build_login_throttle(
|
||||
identity_limit=identity_limit,
|
||||
client_limit=client_limit,
|
||||
window_seconds=window_seconds,
|
||||
key_prefix=key_prefix,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
PasswordRecovery,
|
||||
Tenant,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||
from govoplan_access.backend.security.passwords import hash_password, verify_password
|
||||
from govoplan_access.backend.security.sessions import collect_user_scopes
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
MIN_PASSWORD_LENGTH = 10
|
||||
MAX_PASSWORD_LENGTH = 1024
|
||||
RECOVERY_MINUTES = 15
|
||||
|
||||
|
||||
def local_password_account(account: Account) -> bool:
|
||||
return account.auth_provider == "local"
|
||||
|
||||
|
||||
def password_change_required(account: Account) -> bool:
|
||||
return bool(
|
||||
settings.auth_local_password_recovery_enabled
|
||||
and local_password_account(account)
|
||||
and account.password_reset_required
|
||||
)
|
||||
|
||||
|
||||
def enforce_password_change(account: Account) -> None:
|
||||
if password_change_required(account):
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail={
|
||||
"code": "password_change_required",
|
||||
"message": "Change your initial local password to continue.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def validate_new_password(password: str, account: Account) -> None:
|
||||
if not MIN_PASSWORD_LENGTH <= len(password) <= MAX_PASSWORD_LENGTH:
|
||||
raise HTTPException(
|
||||
422,
|
||||
detail={
|
||||
"code": "invalid_new_password",
|
||||
"message": "Use a password between 10 and 1024 characters.",
|
||||
},
|
||||
)
|
||||
if verify_password(password, account.password_hash):
|
||||
raise HTTPException(
|
||||
422,
|
||||
detail={
|
||||
"code": "password_unchanged",
|
||||
"message": "Choose a different password.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def locked_local_account(session: Session, account_id: str) -> Account:
|
||||
account = (
|
||||
session.query(Account)
|
||||
.filter(Account.id == account_id)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if account is None or not account.is_active or not local_password_account(account):
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail={
|
||||
"code": "local_password_unavailable",
|
||||
"message": "Local password changes are unavailable for this account.",
|
||||
},
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
def recovery_issuer_authorized(
|
||||
session: Session, *, account_id: str, membership_id: str
|
||||
) -> bool:
|
||||
account = session.get(Account, account_id)
|
||||
user = session.get(User, membership_id)
|
||||
tenant = session.get(Tenant, user.tenant_id) if user else None
|
||||
return bool(
|
||||
account
|
||||
and user
|
||||
and tenant
|
||||
and account.is_active
|
||||
and user.is_active
|
||||
and tenant.is_active
|
||||
and user.account_id == account.id
|
||||
and local_password_account(account)
|
||||
and not password_change_required(account)
|
||||
and scopes_grant(
|
||||
collect_user_scopes(session, user, include_system=True), "system:*"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def issue_recovery(
|
||||
session: Session, *, account: Account, issuer: Account, membership: User
|
||||
) -> tuple[str, PasswordRecovery]:
|
||||
# Caller holds the target account lock: issue/redeem/change operations for
|
||||
# that account serialize, and only the latest issued code remains usable.
|
||||
now = utc_now()
|
||||
session.query(PasswordRecovery).filter(
|
||||
PasswordRecovery.account_id == account.id,
|
||||
PasswordRecovery.consumed_at.is_(None),
|
||||
).update({PasswordRecovery.consumed_at: now}, synchronize_session="fetch")
|
||||
code = generate_secret("pr_", random_bytes=32)
|
||||
model = PasswordRecovery(
|
||||
account_id=account.id,
|
||||
issuer_account_id=issuer.id,
|
||||
issuer_membership_id=membership.id,
|
||||
code_hash=hash_secret(code),
|
||||
expires_at=now + timedelta(minutes=RECOVERY_MINUTES),
|
||||
)
|
||||
session.add(model)
|
||||
session.flush()
|
||||
return code, model
|
||||
|
||||
|
||||
def replace_password(
|
||||
session: Session, *, account: Account, password: str
|
||||
) -> dict[str, int]:
|
||||
"""Caller must hold the account lock and commit audit + changes together."""
|
||||
validate_new_password(password, account)
|
||||
now = utc_now()
|
||||
encoded = hash_password(password)
|
||||
replaced = (
|
||||
session.query(Account)
|
||||
.filter(
|
||||
Account.id == account.id,
|
||||
Account.password_hash == account.password_hash,
|
||||
Account.auth_provider == "local",
|
||||
Account.is_active.is_(True),
|
||||
)
|
||||
.update(
|
||||
{Account.password_hash: encoded, Account.password_reset_required: False},
|
||||
synchronize_session="fetch",
|
||||
)
|
||||
)
|
||||
if replaced != 1:
|
||||
raise HTTPException(
|
||||
409,
|
||||
detail={
|
||||
"code": "password_changed_concurrently",
|
||||
"message": "The account changed during authorization. Sign in again.",
|
||||
},
|
||||
)
|
||||
# Keep compatibility membership hashes synchronized; account remains the
|
||||
# only interactive password authority.
|
||||
session.query(User).filter(User.account_id == account.id).update(
|
||||
{User.password_hash: encoded}, synchronize_session="fetch"
|
||||
)
|
||||
sessions = (
|
||||
session.query(AuthSession)
|
||||
.filter(AuthSession.account_id == account.id, AuthSession.revoked_at.is_(None))
|
||||
.update({AuthSession.revoked_at: now}, synchronize_session="fetch")
|
||||
)
|
||||
membership_ids = session.query(User.id).filter(User.account_id == account.id)
|
||||
keys = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id.in_(membership_ids), ApiKey.revoked_at.is_(None))
|
||||
.update({ApiKey.revoked_at: now}, synchronize_session="fetch")
|
||||
)
|
||||
# Treat recovery handoffs as credentials delegated by the issuer too. A
|
||||
# compromised owner's password replacement must not leave previously
|
||||
# issued takeover authorizations for other accounts usable.
|
||||
recoveries = session.query(PasswordRecovery).filter(
|
||||
or_(
|
||||
PasswordRecovery.account_id == account.id,
|
||||
PasswordRecovery.issuer_account_id == account.id,
|
||||
),
|
||||
PasswordRecovery.consumed_at.is_(None),
|
||||
).update({PasswordRecovery.consumed_at: now}, synchronize_session="fetch")
|
||||
return {
|
||||
"revoked_sessions": sessions,
|
||||
"revoked_api_keys": keys,
|
||||
"revoked_password_recoveries": recoveries,
|
||||
}
|
||||
@@ -68,8 +68,17 @@ def list_account_sessions(
|
||||
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||
rows = query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc()).all()
|
||||
summaries = tuple(
|
||||
if not include_inactive:
|
||||
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(
|
||||
item,
|
||||
current_session_id=current_session_id,
|
||||
@@ -77,9 +86,6 @@ def list_account_sessions(
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
@@ -29,6 +29,7 @@ from govoplan_core.tenancy.scope import (
|
||||
create_scope_tables,
|
||||
scope_registry,
|
||||
)
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
class AutomationPrincipalTests(unittest.TestCase):
|
||||
@@ -189,6 +190,14 @@ class AutomationPrincipalTests(unittest.TestCase):
|
||||
suspended.provenance["status"],
|
||||
)
|
||||
|
||||
def test_required_local_password_change_denies_delegated_automation(self) -> None:
|
||||
self.account.password_reset_required = True
|
||||
self.session.commit()
|
||||
with patch.object(settings, "auth_local_password_recovery_enabled", True):
|
||||
result = self.provider.resolve_automation_principal(self.session, request=self._request())
|
||||
self.assertFalse(result.allowed)
|
||||
self.assertEqual("password_change_required", result.provenance["status"])
|
||||
|
||||
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
||||
account = Account(
|
||||
id="service-account-backing",
|
||||
|
||||
@@ -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,37 @@ from govoplan_access.backend.manifest import manifest
|
||||
|
||||
|
||||
class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_password_f1_contexts_resolve_once_to_concise_bilingual_topics(self) -> None:
|
||||
expected = {
|
||||
"access.password.change": "access.help.password-change",
|
||||
"access.password.recover": "access.help.password-recovery",
|
||||
"access.password.issue-recovery": "access.help.password-issue-recovery",
|
||||
}
|
||||
for context, topic_id in expected.items():
|
||||
with self.subTest(context=context):
|
||||
matches = [topic for topic in manifest.documentation if context in (topic.metadata or {}).get("help_contexts", ())]
|
||||
self.assertEqual([topic_id], [topic.id for topic in matches])
|
||||
topic = matches[0]
|
||||
self.assertEqual({"admin", "user"}, set(topic.documentation_types))
|
||||
for body in (topic.body, topic.translations["de"]["body"]):
|
||||
self.assertLessEqual(len(body.split()), 180)
|
||||
self.assertIn("API", body)
|
||||
self.assertIn("URLs", body)
|
||||
if context != "access.password.change":
|
||||
self.assertIn("15 minutes", topic.body)
|
||||
self.assertIn("15 Minuten", topic.translations["de"]["body"])
|
||||
|
||||
def test_password_change_flag_documents_opt_in_and_complete_recovery(self) -> None:
|
||||
topic = next(item for item in manifest.documentation if item.id == "access.reference.authentication-fields")
|
||||
self.assertIn("defaults to false", topic.body)
|
||||
self.assertIn("remains advisory metadata", topic.body)
|
||||
self.assertIn("standardmäßig false", topic.translations["de"]["body"])
|
||||
recovery = next(item for item in manifest.documentation if item.id == "access.workflow.local-password-recovery")
|
||||
for required in ("15 minutes", "single-use", "system:*", "human API keys", "e9a2c5f8b1d4", "does not send email"):
|
||||
self.assertIn(required, recovery.body)
|
||||
self.assertIn("issued by that account for other people", recovery.body)
|
||||
self.assertIn("von diesem Konto für andere Personen ausgestellte", recovery.translations["de"]["body"])
|
||||
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
|
||||
@@ -18,7 +18,7 @@ class LoginSecurityTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _session_with_account(account: object | None) -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.one_or_none.return_value = (
|
||||
session.query.return_value.filter.return_value.populate_existing.return_value.with_for_update.return_value.one_or_none.return_value = (
|
||||
account
|
||||
)
|
||||
return session
|
||||
@@ -43,7 +43,7 @@ class LoginSecurityTests(unittest.TestCase):
|
||||
self,
|
||||
) -> None:
|
||||
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
|
||||
account = SimpleNamespace(password_hash=account_hash)
|
||||
account = SimpleNamespace(password_hash=account_hash, auth_provider="local")
|
||||
payload = LoginRequest(email="known@example.test", password="wrong-password")
|
||||
|
||||
with patch.object(auth, "verify_password", return_value=False) as verifier:
|
||||
@@ -55,7 +55,7 @@ class LoginSecurityTests(unittest.TestCase):
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
|
||||
account = SimpleNamespace(password_hash=None)
|
||||
account = SimpleNamespace(password_hash=None, auth_provider="local")
|
||||
payload = LoginRequest(
|
||||
email="passwordless@example.test", password="not-a-user-password"
|
||||
)
|
||||
@@ -69,6 +69,7 @@ class LoginSecurityTests(unittest.TestCase):
|
||||
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
|
||||
account = SimpleNamespace(
|
||||
id="account-1",
|
||||
auth_provider="local",
|
||||
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
|
||||
)
|
||||
session = self._session_with_account(account)
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
AccessApiPrincipalProvider,
|
||||
get_api_principal,
|
||||
)
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
PasswordRecovery,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_access.backend.security.login_throttle import (
|
||||
InMemoryLoginAttemptStore,
|
||||
LoginThrottle,
|
||||
)
|
||||
from govoplan_access.backend.security.passwords import hash_password, verify_password
|
||||
from govoplan_access.backend.security.password_change import replace_password
|
||||
from govoplan_access.backend.security.sessions import create_auth_session
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.auth import get_api_principal as get_core_api_principal
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.change_sequence import (
|
||||
ChangeSequenceEntry,
|
||||
ChangeSequenceRetentionFloor,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
|
||||
|
||||
class PasswordRecoveryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(self.engine)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
SystemSettings.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
self.factory = sessionmaker(bind=self.engine)
|
||||
self.db = self.factory()
|
||||
self.tenant = Tenant(id="tenant", slug="tenant", name="Tenant")
|
||||
self.account = Account(
|
||||
id="person",
|
||||
email="person@example.test",
|
||||
normalized_email="person@example.test",
|
||||
password_hash=hash_password("Initial-password"),
|
||||
password_reset_required=True,
|
||||
)
|
||||
self.owner = Account(
|
||||
id="owner",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
password_hash=hash_password("Owner-password"),
|
||||
)
|
||||
self.user = User(
|
||||
id="person-member",
|
||||
account_id="person",
|
||||
tenant_id="tenant",
|
||||
email=self.account.email,
|
||||
)
|
||||
self.owner_user = User(
|
||||
id="owner-member",
|
||||
account_id="owner",
|
||||
tenant_id="tenant",
|
||||
email=self.owner.email,
|
||||
)
|
||||
role = Role(
|
||||
id="owner-role",
|
||||
tenant_id=None,
|
||||
slug="system_owner",
|
||||
name="System owner",
|
||||
permissions=["system:*"],
|
||||
)
|
||||
self.owner_assignment = SystemRoleAssignment(
|
||||
id="owner-assignment", account_id="owner", role_id=role.id
|
||||
)
|
||||
self.db.add_all(
|
||||
[
|
||||
self.tenant,
|
||||
self.account,
|
||||
self.owner,
|
||||
self.user,
|
||||
self.owner_user,
|
||||
role,
|
||||
self.owner_assignment,
|
||||
]
|
||||
)
|
||||
self.db.flush()
|
||||
self.current = create_auth_session(self.db, user=self.user)
|
||||
self.other = create_auth_session(self.db, user=self.user)
|
||||
self.owner_session = create_auth_session(self.db, user=self.owner_user)
|
||||
self.key = create_api_key(
|
||||
self.db, user=self.user, name="Human automation", scopes=[]
|
||||
)
|
||||
self.db.commit()
|
||||
self.audit = patch.object(auth, "audit_event").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
patch.object(settings, "auth_local_password_recovery_enabled", True).start()
|
||||
patch.object(settings, "auth_principal_cache_enabled", True).start()
|
||||
patch.object(settings, "auth_login_throttle_enabled", False).start()
|
||||
self.throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=50,
|
||||
client_limit=100,
|
||||
window_seconds=900,
|
||||
)
|
||||
patch.object(
|
||||
auth, "_password_operation_throttle", return_value=self.throttle
|
||||
).start()
|
||||
app = FastAPI()
|
||||
registry = PlatformRegistry()
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=settings)
|
||||
)
|
||||
registry.register_capability_factory(
|
||||
"access",
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
lambda _context: AccessApiPrincipalProvider(),
|
||||
)
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
|
||||
@app.get("/protected")
|
||||
def protected(principal=Depends(get_api_principal)):
|
||||
return {"account": principal.account_id}
|
||||
|
||||
@app.get("/core-protected")
|
||||
def core_protected(principal=Depends(get_core_api_principal)):
|
||||
return {"account": principal.account_id}
|
||||
|
||||
def session_dependency():
|
||||
with self.factory() as session:
|
||||
try:
|
||||
yield session
|
||||
except BaseException:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
app.dependency_overrides[get_session] = session_dependency
|
||||
self.client = TestClient(app)
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.client.close()
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def headers(self, created=None):
|
||||
return {"authorization": "Bearer " + (created or self.current).token}
|
||||
|
||||
def change(self, **overrides):
|
||||
return self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers=self.headers(),
|
||||
json={
|
||||
"current_password": "Initial-password",
|
||||
"new_password": "New-secret-password",
|
||||
**overrides,
|
||||
},
|
||||
)
|
||||
|
||||
def issue(self):
|
||||
return self.client.post(
|
||||
"/api/v1/auth/password/recovery/person",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={"current_password": "Owner-password", "identity_verified": True},
|
||||
)
|
||||
|
||||
def recover(self, code, **overrides):
|
||||
return self.client.post(
|
||||
"/api/v1/auth/password/recover",
|
||||
json={
|
||||
"email": "person@example.test",
|
||||
"recovery_code": code,
|
||||
"new_password": "Recovered-password",
|
||||
**overrides,
|
||||
},
|
||||
)
|
||||
|
||||
def test_first_login_restricts_then_rotates_and_revokes_all_credentials(self):
|
||||
login = self.client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": self.account.email, "password": "Initial-password"},
|
||||
)
|
||||
self.assertEqual(200, login.status_code, login.text)
|
||||
self.assertEqual(
|
||||
"change_password", login.json()["user"]["required_auth_action"]
|
||||
)
|
||||
self.assertEqual([], login.json()["scopes"])
|
||||
self.assertEqual(
|
||||
403, self.client.get("/protected", headers=self.headers()).status_code
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.patch(
|
||||
"/api/v1/auth/profile",
|
||||
headers=self.headers(),
|
||||
json={"display_name": "Escaped"},
|
||||
).status_code,
|
||||
)
|
||||
for path in ("session", "shell"):
|
||||
result = self.client.get("/api/v1/auth/" + path, headers=self.headers())
|
||||
self.assertEqual(200, result.status_code, result.text)
|
||||
self.assertEqual(
|
||||
"change_password", result.json()["user"]["required_auth_action"]
|
||||
)
|
||||
changed = self.change()
|
||||
self.assertEqual(200, changed.status_code, changed.text)
|
||||
self.assertIsNone(changed.json()["user"]["required_auth_action"])
|
||||
self.assertNotEqual(self.current.token, changed.json()["access_token"])
|
||||
for old in (self.current.token, self.other.token, self.key.secret):
|
||||
self.assertEqual(
|
||||
401,
|
||||
self.client.get(
|
||||
"/protected", headers={"authorization": "Bearer " + old}
|
||||
).status_code,
|
||||
)
|
||||
self.assertEqual(
|
||||
200,
|
||||
self.client.get(
|
||||
"/protected",
|
||||
headers={"authorization": "Bearer " + changed.json()["access_token"]},
|
||||
).status_code,
|
||||
)
|
||||
self.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password("New-secret-password", self.account.password_hash)
|
||||
)
|
||||
self.assertTrue(verify_password("New-secret-password", self.user.password_hash))
|
||||
self.assertFalse(self.account.password_reset_required)
|
||||
self.assertNotIn("New-secret-password", str(self.audit.call_args_list))
|
||||
self.assertNotIn("Initial-password", str(self.audit.call_args_list))
|
||||
|
||||
def test_failed_current_password_leaves_credentials_and_flag_unchanged(self):
|
||||
result = self.change(current_password="wrong-password")
|
||||
self.assertEqual(403, result.status_code, result.text)
|
||||
self.db.expire_all()
|
||||
self.assertTrue(self.account.password_reset_required)
|
||||
self.assertIsNone(self.db.get(AuthSession, self.current.model.id).revoked_at)
|
||||
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||
self.assertEqual(422, self.change(new_password="Initial-password").status_code)
|
||||
|
||||
def test_core_api_provider_enforces_current_flag_for_sessions_and_keys(self):
|
||||
for headers in (self.headers(), {"x-api-key": self.key.secret}):
|
||||
with patch.object(settings, "auth_local_password_recovery_enabled", False):
|
||||
self.assertEqual(
|
||||
200, self.client.get("/core-protected", headers=headers).status_code
|
||||
)
|
||||
result = self.client.get("/core-protected", headers=headers)
|
||||
self.assertEqual(403, result.status_code)
|
||||
self.assertEqual(
|
||||
"password_change_required", result.json()["detail"]["code"]
|
||||
)
|
||||
|
||||
def test_validation_responses_do_not_echo_rejected_secrets(self):
|
||||
response = self.change(new_password="tiny")
|
||||
self.assertEqual(422, response.status_code)
|
||||
self.assertNotIn("tiny", response.text)
|
||||
self.assertNotIn("Initial-password", response.text)
|
||||
self.assertNotIn("input", response.json()["detail"][0])
|
||||
|
||||
def test_cookie_change_requires_matching_csrf_and_replaces_csrf(self):
|
||||
self.client.cookies.set(settings.auth_session_cookie_name, self.current.token)
|
||||
self.client.cookies.set(settings.auth_csrf_cookie_name, self.current.csrf_token)
|
||||
payload = {
|
||||
"current_password": "Initial-password",
|
||||
"new_password": "New-secret-password",
|
||||
}
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.post("/api/v1/auth/password/change", json=payload).status_code,
|
||||
)
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers={"x-csrf-token": self.current.csrf_token},
|
||||
json=payload,
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertIn("HttpOnly", response.headers["set-cookie"])
|
||||
self.assertNotIn(self.current.csrf_token, response.headers["set-cookie"])
|
||||
|
||||
def test_opt_in_preserves_advisory_behavior_and_still_allows_change(self):
|
||||
with patch.object(settings, "auth_local_password_recovery_enabled", False):
|
||||
self.assertEqual(
|
||||
200, self.client.get("/protected", headers=self.headers()).status_code
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.client.get("/api/v1/auth/session", headers=self.headers()).json()[
|
||||
"user"
|
||||
]["required_auth_action"]
|
||||
)
|
||||
self.assertEqual(409, self.issue().status_code)
|
||||
self.assertEqual(409, self.recover("unknown").status_code)
|
||||
self.assertEqual(200, self.change().status_code)
|
||||
|
||||
def test_warm_cache_and_human_api_keys_do_not_bypass_flag(self):
|
||||
self.account.password_reset_required = False
|
||||
self.db.commit()
|
||||
self.assertEqual(
|
||||
200, self.client.get("/protected", headers=self.headers()).status_code
|
||||
)
|
||||
self.assertEqual(
|
||||
200,
|
||||
self.client.get(
|
||||
"/protected", headers={"x-api-key": self.key.secret}
|
||||
).status_code,
|
||||
)
|
||||
self.account.password_reset_required = True
|
||||
self.db.commit()
|
||||
self.assertEqual(
|
||||
403, self.client.get("/protected", headers=self.headers()).status_code
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.get(
|
||||
"/protected", headers={"x-api-key": self.key.secret}
|
||||
).status_code,
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.get(
|
||||
"/api/v1/auth/session", headers={"x-api-key": self.key.secret}
|
||||
).status_code,
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers={"x-api-key": self.key.secret},
|
||||
json={
|
||||
"current_password": "Initial-password",
|
||||
"new_password": "New-secret-password",
|
||||
},
|
||||
).status_code,
|
||||
)
|
||||
self.account.password_reset_required = False
|
||||
self.db.commit()
|
||||
self.assertEqual(
|
||||
400,
|
||||
self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers={"x-api-key": self.key.secret},
|
||||
json={
|
||||
"current_password": "Initial-password",
|
||||
"new_password": "New-secret-password",
|
||||
},
|
||||
).status_code,
|
||||
)
|
||||
|
||||
def test_external_provider_is_not_forced_or_allowed_to_change_local_password(self):
|
||||
for provider in ("oidc", "service_account"):
|
||||
with self.subTest(provider=provider):
|
||||
self.account.auth_provider = provider
|
||||
self.db.commit()
|
||||
session_info = self.client.get(
|
||||
"/api/v1/auth/session", headers=self.headers()
|
||||
)
|
||||
self.assertIsNone(session_info.json()["user"]["required_auth_action"])
|
||||
self.assertFalse(session_info.json()["user"]["local_password"])
|
||||
self.assertEqual(403, self.change().status_code)
|
||||
self.assertEqual(
|
||||
401,
|
||||
self.client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": self.account.email,
|
||||
"password": "Initial-password",
|
||||
},
|
||||
).status_code,
|
||||
)
|
||||
|
||||
def test_recovery_code_is_hashed_one_use_and_revokes_sessions_and_keys(self):
|
||||
response = self.issue()
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
code = response.json()["recovery_code"]
|
||||
stored = self.db.query(PasswordRecovery).one()
|
||||
self.assertNotEqual(code, stored.code_hash)
|
||||
self.assertNotIn(code, str(self.audit.call_args_list))
|
||||
self.assertEqual("no-store", response.headers["cache-control"])
|
||||
self.assertEqual(200, self.recover(code).status_code)
|
||||
self.assertEqual(
|
||||
400, self.recover(code, new_password="Another-secret").status_code
|
||||
)
|
||||
self.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password("Recovered-password", self.account.password_hash)
|
||||
)
|
||||
self.assertFalse(self.account.password_reset_required)
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.db.query(AuthSession)
|
||||
.filter(
|
||||
AuthSession.account_id == self.account.id,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
self.assertIsNotNone(self.db.get(ApiKey, self.key.model.id).revoked_at)
|
||||
|
||||
def test_expiry_supersession_and_current_issuer_authority(self):
|
||||
first = self.issue().json()["recovery_code"]
|
||||
second = self.issue().json()["recovery_code"]
|
||||
self.assertEqual(400, self.recover(first).status_code)
|
||||
latest = (
|
||||
self.db.query(PasswordRecovery)
|
||||
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||
.one()
|
||||
)
|
||||
latest.expires_at = utc_now() - timedelta(seconds=1)
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(second).status_code)
|
||||
third = self.issue().json()["recovery_code"]
|
||||
self.db.delete(self.owner_assignment)
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(third).status_code)
|
||||
self.assertEqual(403, self.issue().status_code)
|
||||
|
||||
def test_recovery_rechecks_account_and_membership_state(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
self.user.is_active = False
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
self.user.is_active = True
|
||||
self.account.auth_provider = "oidc"
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
|
||||
def test_recovery_rechecks_tenant_and_issuer_activation(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
self.tenant.is_active = False
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
self.tenant.is_active = True
|
||||
self.owner.is_active = False
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
|
||||
def test_recovery_requires_current_owner_password_and_identity_verification(self):
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password/recovery/person",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={"current_password": "wrong-password", "identity_verified": True},
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password/recovery/person",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={"current_password": "Owner-password", "identity_verified": False},
|
||||
)
|
||||
self.assertEqual(422, response.status_code)
|
||||
self.assertEqual(0, self.db.query(PasswordRecovery).count())
|
||||
role = self.db.get(Role, "owner-role")
|
||||
role.permissions = ["system:accounts:update"]
|
||||
self.db.commit()
|
||||
self.assertEqual(403, self.issue().status_code)
|
||||
|
||||
def test_invalid_recovery_email_or_unchanged_password_does_not_consume_code(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
response = self.recover(code, email="different@example.test")
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.assertEqual(
|
||||
422, self.recover(code, new_password="Initial-password").status_code
|
||||
)
|
||||
self.assertEqual(200, self.recover(code).status_code)
|
||||
|
||||
def test_recovery_abuse_is_bounded_even_with_login_throttle_disabled(self):
|
||||
throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=2,
|
||||
client_limit=100,
|
||||
window_seconds=900,
|
||||
)
|
||||
with patch.object(auth, "_password_operation_throttle", return_value=throttle):
|
||||
self.assertEqual(400, self.recover("wrong-code").status_code)
|
||||
response = self.recover("different-wrong-code")
|
||||
self.assertEqual(429, response.status_code)
|
||||
self.assertIn("retry-after", response.headers)
|
||||
|
||||
def test_failed_audit_rolls_back_password_and_session_rotation(self):
|
||||
with patch.object(
|
||||
auth, "audit_event", side_effect=RuntimeError("audit unavailable")
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.change()
|
||||
self.db.expire_all()
|
||||
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||
self.assertIsNone(self.db.get(AuthSession, self.current.model.id).revoked_at)
|
||||
|
||||
def test_stale_password_authorization_cannot_overwrite_concurrent_change(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
with self.factory() as competing:
|
||||
current = competing.get(Account, self.account.id)
|
||||
replace_password(competing, account=current, password="Concurrent-password")
|
||||
competing.commit()
|
||||
with self.assertRaises(HTTPException) as conflict:
|
||||
# This intentionally uses the account state read before the other
|
||||
# transaction, modelling a database without row-lock support.
|
||||
replace_password(self.db, account=self.account, password="Stale-password")
|
||||
self.assertEqual(409, conflict.exception.status_code)
|
||||
self.db.rollback()
|
||||
self.db.refresh(self.account)
|
||||
self.assertTrue(
|
||||
verify_password("Concurrent-password", self.account.password_hash)
|
||||
)
|
||||
|
||||
def test_current_password_change_invalidates_outstanding_recovery(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
self.assertEqual(200, self.change().status_code)
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
|
||||
def test_owner_password_change_invalidates_codes_issued_for_other_accounts(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
changed = self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={
|
||||
"current_password": "Owner-password",
|
||||
"new_password": "Owner-new-password",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, changed.status_code, changed.text)
|
||||
rejected = self.recover(code)
|
||||
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||
self.db.expire_all()
|
||||
self.assertIsNotNone(self.db.query(PasswordRecovery).one().consumed_at)
|
||||
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||
self.assertEqual(
|
||||
1, self.audit.call_args.kwargs["details"]["revoked_password_recoveries"]
|
||||
)
|
||||
|
||||
def test_owner_password_recovery_invalidates_codes_issued_for_other_accounts(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
owner_recovery = self.client.post(
|
||||
"/api/v1/auth/password/recovery/owner",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={"current_password": "Owner-password", "identity_verified": True},
|
||||
)
|
||||
self.assertEqual(200, owner_recovery.status_code, owner_recovery.text)
|
||||
recovered = self.recover(
|
||||
owner_recovery.json()["recovery_code"],
|
||||
email=self.owner.email,
|
||||
new_password="Owner-recovered-password",
|
||||
)
|
||||
self.assertEqual(200, recovered.status_code, recovered.text)
|
||||
rejected = self.recover(code)
|
||||
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||
self.db.expire_all()
|
||||
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||
self.assertTrue(
|
||||
verify_password("Owner-recovered-password", self.owner.password_hash)
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.db.query(PasswordRecovery)
|
||||
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||
.count(),
|
||||
)
|
||||
|
||||
def test_flagged_account_can_sign_out_and_requires_csrf_for_cookie_logout(self):
|
||||
self.client.cookies.set(settings.auth_session_cookie_name, self.current.token)
|
||||
self.client.cookies.set(settings.auth_csrf_cookie_name, self.current.csrf_token)
|
||||
self.assertEqual(403, self.client.post("/api/v1/auth/logout").status_code)
|
||||
result = self.client.post(
|
||||
"/api/v1/auth/logout", headers={"x-csrf-token": self.current.csrf_token}
|
||||
)
|
||||
self.assertEqual(200, result.status_code)
|
||||
self.assertEqual(
|
||||
401,
|
||||
self.client.get("/api/v1/auth/session", headers=self.headers()).status_code,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_core.db.migrations import alembic_config
|
||||
|
||||
|
||||
class PasswordRecoveryMigrationTests(unittest.TestCase):
|
||||
def test_release_and_development_upgrade_preserve_existing_credentials(self):
|
||||
for track in ("release", "dev"):
|
||||
with (
|
||||
self.subTest(track=track),
|
||||
tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-password-migration-"
|
||||
) as directory,
|
||||
):
|
||||
url = f"sqlite:///{Path(directory) / 'isolated-upgrade.db'}"
|
||||
config = alembic_config(
|
||||
database_url=url, enabled_modules=("access",), migration_track=track
|
||||
)
|
||||
command.upgrade(config, "4f2a9c8e7b6d")
|
||||
command.upgrade(config, "d8f1b4e7a0c3")
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Account(
|
||||
id="existing",
|
||||
email="existing@example.test",
|
||||
normalized_email="existing@example.test",
|
||||
password_hash=hash_password("Existing-password"),
|
||||
password_reset_required=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with engine.connect() as connection:
|
||||
before = list(
|
||||
connection.execute(
|
||||
text("SELECT * FROM access_accounts")
|
||||
).mappings()
|
||||
)
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
command.upgrade(config, "e9a2c5f8b1d4")
|
||||
command.upgrade(config, "e9a2c5f8b1d4")
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
self.assertEqual(
|
||||
tables | {"access_password_recoveries"},
|
||||
set(inspector.get_table_names()),
|
||||
)
|
||||
self.assertEqual(
|
||||
before,
|
||||
list(
|
||||
connection.execute(
|
||||
text("SELECT * FROM access_accounts")
|
||||
).mappings()
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"id",
|
||||
"account_id",
|
||||
"issuer_account_id",
|
||||
"issuer_membership_id",
|
||||
"code_hash",
|
||||
"expires_at",
|
||||
"consumed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
},
|
||||
{
|
||||
column["name"]
|
||||
for column in inspector.get_columns(
|
||||
"access_password_recoveries"
|
||||
)
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
3,
|
||||
len(
|
||||
inspector.get_foreign_keys("access_password_recoveries")
|
||||
),
|
||||
)
|
||||
self.assertIn(
|
||||
["code_hash"],
|
||||
[
|
||||
constraint["column_names"]
|
||||
for constraint in inspector.get_unique_constraints(
|
||||
"access_password_recoveries"
|
||||
)
|
||||
],
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
import test_password_recovery as recovery_fixture
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.db.models import PasswordRecovery
|
||||
from govoplan_access.backend.security.passwords import verify_password
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get("GOVOPLAN_ACCESS_TEST_POSTGRES_URL"),
|
||||
"set GOVOPLAN_ACCESS_TEST_POSTGRES_URL to a disposable PostgreSQL test database",
|
||||
)
|
||||
class PasswordRecoveryPostgresTests(unittest.TestCase):
|
||||
"""Opt-in real transaction races; no application/default database fallback."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
url = make_url(os.environ["GOVOPLAN_ACCESS_TEST_POSTGRES_URL"])
|
||||
if url.get_backend_name() != "postgresql" or not url.database:
|
||||
raise ValueError("An explicit disposable PostgreSQL database is required")
|
||||
self.schema = "access_password_race_" + uuid.uuid4().hex
|
||||
self.admin_engine = create_engine(url)
|
||||
self.addCleanup(self.admin_engine.dispose)
|
||||
with self.admin_engine.begin() as connection:
|
||||
connection.execute(text(f'CREATE SCHEMA "{self.schema}"'))
|
||||
self.addCleanup(self._drop_schema)
|
||||
self.engine = create_engine(
|
||||
url,
|
||||
connect_args={
|
||||
"options": (
|
||||
f"-c search_path={self.schema} -c statement_timeout=30000 "
|
||||
"-c lock_timeout=15000 -c idle_in_transaction_session_timeout=60000"
|
||||
)
|
||||
},
|
||||
)
|
||||
self.addCleanup(self.engine.dispose)
|
||||
self.access = recovery_fixture.PasswordRecoveryTests(methodName="runTest")
|
||||
self.addCleanup(self._close_fixture)
|
||||
# Reuse the synthetic HTTP/security fixture, replacing only its SQLite
|
||||
# engine. The production account locks/CAS and request SQL sessions run.
|
||||
with patch.object(recovery_fixture, "create_engine", return_value=self.engine):
|
||||
self.access.setUp()
|
||||
|
||||
def _drop_schema(self) -> None:
|
||||
# The identifier is generated here, never read from the supplied URL.
|
||||
with self.admin_engine.begin() as connection:
|
||||
connection.execute(text(f'DROP SCHEMA "{self.schema}" CASCADE'))
|
||||
|
||||
def _close_fixture(self) -> None:
|
||||
try:
|
||||
if hasattr(self.access, "client"):
|
||||
self.access.client.close()
|
||||
if hasattr(self.access, "db"):
|
||||
self.access.db.close()
|
||||
finally:
|
||||
self.access.doCleanups()
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def _post(self, endpoint, payload, headers=None):
|
||||
# Separate cookie jars and real per-request SQL sessions in each thread.
|
||||
with TestClient(self.access.client.app) as client:
|
||||
return client.post(
|
||||
"/api/v1/auth/password/" + endpoint,
|
||||
json=payload,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
def _race(self, endpoint, payloads, *, headers=None, locked_account="person"):
|
||||
barrier = threading.Barrier(2)
|
||||
real_lock = auth.locked_local_account
|
||||
|
||||
def synchronized_lock(session, account_id):
|
||||
if account_id == locked_account:
|
||||
barrier.wait(timeout=10)
|
||||
return real_lock(session, account_id)
|
||||
|
||||
with patch.object(auth, "locked_local_account", side_effect=synchronized_lock):
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(self._post, endpoint, payload, headers)
|
||||
for payload in payloads
|
||||
]
|
||||
return [future.result(timeout=35) for future in futures]
|
||||
|
||||
def test_same_recovery_code_has_exactly_one_winner(self):
|
||||
issued = self.access.issue()
|
||||
self.assertEqual(200, issued.status_code)
|
||||
code = issued.json()["recovery_code"]
|
||||
passwords = ["Concurrent-recovery-one", "Concurrent-recovery-two"]
|
||||
responses = self._race(
|
||||
"recover",
|
||||
[
|
||||
{
|
||||
"email": "person@example.test",
|
||||
"recovery_code": code,
|
||||
"new_password": value,
|
||||
}
|
||||
for value in passwords
|
||||
],
|
||||
)
|
||||
self.assertEqual([200, 400], sorted(item.status_code for item in responses))
|
||||
winner = next(
|
||||
index for index, item in enumerate(responses) if item.status_code == 200
|
||||
)
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password(passwords[winner], self.access.account.password_hash)
|
||||
)
|
||||
self.assertIsNotNone(self.access.db.query(PasswordRecovery).one().consumed_at)
|
||||
|
||||
def test_same_old_password_has_exactly_one_winner(self):
|
||||
passwords = ["Concurrent-change-one", "Concurrent-change-two"]
|
||||
responses = self._race(
|
||||
"change",
|
||||
[
|
||||
{"current_password": "Initial-password", "new_password": value}
|
||||
for value in passwords
|
||||
],
|
||||
headers=self.access.headers(),
|
||||
)
|
||||
self.assertEqual([200, 401], sorted(item.status_code for item in responses))
|
||||
winner = next(
|
||||
index for index, item in enumerate(responses) if item.status_code == 200
|
||||
)
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password(passwords[winner], self.access.account.password_hash)
|
||||
)
|
||||
|
||||
def test_competing_issuance_leaves_exactly_one_usable_code(self):
|
||||
responses = self._race(
|
||||
"recovery/person",
|
||||
[{"current_password": "Owner-password", "identity_verified": True}] * 2,
|
||||
headers=self.access.headers(self.access.owner_session),
|
||||
locked_account="owner",
|
||||
)
|
||||
self.assertEqual([200, 200], [item.status_code for item in responses])
|
||||
codes = [item.json()["recovery_code"] for item in responses]
|
||||
self.assertEqual(2, len(set(codes)))
|
||||
self.access.db.expire_all()
|
||||
self.assertEqual(
|
||||
1,
|
||||
self.access.db.query(PasswordRecovery)
|
||||
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||
.count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
[200, 400], sorted(self.access.recover(code).status_code for code in codes)
|
||||
)
|
||||
|
||||
def test_issuer_password_revocation_wins_paused_redemption(self):
|
||||
issued = self.access.issue()
|
||||
self.assertEqual(200, issued.status_code)
|
||||
code = issued.json()["recovery_code"]
|
||||
reached = threading.Event()
|
||||
proceed = threading.Event()
|
||||
real_authorized = auth.recovery_issuer_authorized
|
||||
|
||||
def paused_authorization(*args, **kwargs):
|
||||
reached.set()
|
||||
self.assertTrue(proceed.wait(timeout=35))
|
||||
return real_authorized(*args, **kwargs)
|
||||
|
||||
with patch.object(
|
||||
auth, "recovery_issuer_authorized", side_effect=paused_authorization
|
||||
):
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
redemption = executor.submit(
|
||||
self._post,
|
||||
"recover",
|
||||
{
|
||||
"email": "person@example.test",
|
||||
"recovery_code": code,
|
||||
"new_password": "Must-not-replace-password",
|
||||
},
|
||||
)
|
||||
try:
|
||||
self.assertTrue(reached.wait(timeout=10))
|
||||
changed = self._post(
|
||||
"change",
|
||||
{
|
||||
"current_password": "Owner-password",
|
||||
"new_password": "Owner-race-password",
|
||||
},
|
||||
self.access.headers(self.access.owner_session),
|
||||
)
|
||||
self.assertEqual(200, changed.status_code)
|
||||
finally:
|
||||
proceed.set()
|
||||
rejected = redemption.result(timeout=35)
|
||||
self.assertEqual(400, rejected.status_code)
|
||||
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password("Initial-password", self.access.account.password_hash)
|
||||
)
|
||||
self.assertTrue(
|
||||
verify_password("Owner-race-password", self.access.owner.password_hash)
|
||||
)
|
||||
self.assertIsNotNone(self.access.db.query(PasswordRecovery).one().consumed_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -30,6 +30,34 @@ class PermissionCatalogContractTests(unittest.TestCase):
|
||||
self.assertIn("files:file: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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
@@ -182,6 +182,36 @@ class SessionManagementTests(unittest.TestCase):
|
||||
self.assertIsNone(hidden)
|
||||
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:
|
||||
with self.assertRaisesRegex(ValueError, "current session"):
|
||||
revoke_account_session(
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
|
||||
"test:passwords": "node --test scripts/test-passwords.mjs"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
@@ -16,7 +17,7 @@
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Use the workspace's shared frontend compiler, without loading the application.
|
||||
const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
|
||||
const { transformSync } = require("esbuild");
|
||||
const calls = [];
|
||||
function load(relativePath, imports = {}) {
|
||||
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
const code = transformSync(source, { loader: "ts", format: "cjs", target: "es2022" }).code;
|
||||
const context = vm.createContext({ module: { exports: {} }, require: () => imports });
|
||||
context.exports = context.module.exports;
|
||||
vm.runInContext(code, context);
|
||||
return context.module.exports;
|
||||
}
|
||||
const api = load("../src/api/passwords.ts", {
|
||||
apiFetch: (...args) => { calls.push(args); return Promise.resolve({}); },
|
||||
isApiError: (error) => Boolean(error?.fixtureApiError)
|
||||
});
|
||||
const { passwordTranslations } = load("../src/i18n/passwordTranslations.ts");
|
||||
const settings = { apiBaseUrl: "https://fixture.invalid", apiKey: "fixture-key", accessToken: "legacy-fixture-token" };
|
||||
const current = "fixture-current-password";
|
||||
const next = "fixture-next-password";
|
||||
const code = "fixture-recovery-code";
|
||||
|
||||
test("password requests carry credentials only in POST bodies and public calls discard bearer settings", async () => {
|
||||
calls.length = 0;
|
||||
await api.fetchPasswordPolicy(settings);
|
||||
await api.changePassword(settings, current, next);
|
||||
await api.issuePasswordRecovery(settings, "account/1", current, true);
|
||||
await api.recoverPassword(settings, "person@example.test", code, next);
|
||||
assert.equal(calls[0][0].apiKey, "");
|
||||
assert.equal(calls[0][0].accessToken, "");
|
||||
assert.equal(calls[0][2].cache, "no-store");
|
||||
assert.equal(calls[1][0], settings);
|
||||
assert.deepEqual(JSON.parse(calls[1][2].body), { current_password: current, new_password: next });
|
||||
assert.equal(calls[2][1], "/api/v1/auth/password/recovery/account%2F1");
|
||||
assert.deepEqual(JSON.parse(calls[2][2].body), { current_password: current, identity_verified: true });
|
||||
assert.equal(calls[3][0].apiKey, "");
|
||||
assert.equal(calls[3][0].accessToken, "");
|
||||
assert.deepEqual(JSON.parse(calls[3][2].body), { email: "person@example.test", recovery_code: code, new_password: next });
|
||||
for (const [, path, options] of calls.slice(1)) {
|
||||
assert.equal(options.method, "POST");
|
||||
for (const secret of [current, next, code]) assert.equal(path.includes(secret), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("all stable password errors have EN/DE messages, while arbitrary input never becomes display text", () => {
|
||||
const codes = ["current_password_invalid", "invalid_new_password", "password_unchanged", "password_recovery_disabled", "password_rate_limited", "local_password_unavailable", "recovery_invalid", "recovery_issuer_required", "recovery_membership_required", "password_changed_concurrently", "password_change_required"];
|
||||
for (const value of codes) {
|
||||
const key = api.passwordErrorMessage({ fixtureApiError: true, status: 400, body: JSON.stringify({ detail: { code: value, input: current } }) });
|
||||
assert.ok(passwordTranslations.en[key], value);
|
||||
assert.ok(passwordTranslations.de[key], value);
|
||||
assert.notEqual(key, "i18n:govoplan-access.password.request_failed");
|
||||
}
|
||||
for (const body of [current, JSON.stringify({ detail: [{ input: current }] }), JSON.stringify({ detail: { code: "toString", input: code } })]) {
|
||||
assert.equal(api.passwordErrorMessage({ fixtureApiError: true, status: 500, body }), "i18n:govoplan-access.password.request_failed");
|
||||
}
|
||||
assert.equal(api.passwordErrorMessage(new Error(current)), "i18n:govoplan-access.password.request_failed");
|
||||
for (const status of [401, 403, 422, 429]) {
|
||||
const key = api.passwordErrorMessage({ fixtureApiError: true, status, body: JSON.stringify({ detail: current }) });
|
||||
assert.ok(passwordTranslations.en[key]);
|
||||
assert.ok(passwordTranslations.de[key]);
|
||||
assert.equal(key.includes(current), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("password translations keep the EN/DE workflow and placeholder contracts aligned", () => {
|
||||
assert.deepEqual(Object.keys(passwordTranslations.en).sort(), Object.keys(passwordTranslations.de).sort());
|
||||
for (const key of Object.keys(passwordTranslations.en)) {
|
||||
assert.deepEqual(passwordTranslations.en[key].match(/\{value\d+\}/g) ?? [], passwordTranslations.de[key].match(/\{value\d+\}/g) ?? [], key);
|
||||
}
|
||||
});
|
||||
@@ -4,8 +4,7 @@ import type {
|
||||
DeltaDeletedItem,
|
||||
PrivacyRetentionPolicy,
|
||||
ResourceAccessExplanationOptions,
|
||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
|
||||
TenantAdminItem
|
||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
@@ -137,6 +136,7 @@ export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationRes
|
||||
|
||||
export type SystemAccountItem = {
|
||||
account_id: string;
|
||||
local_password?: boolean;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
is_active: boolean;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { apiFetch, isApiError, type ApiSettings, type LoginResponse } from "@govoplan/core-webui";
|
||||
|
||||
export type PasswordPolicy = {
|
||||
recovery_enabled: boolean;
|
||||
min_length: number;
|
||||
max_length: number;
|
||||
recovery_minutes: number;
|
||||
};
|
||||
|
||||
export type PasswordRecoveryCode = { recovery_code: string; expires_at: string };
|
||||
|
||||
export function fetchPasswordPolicy(settings: ApiSettings): Promise<PasswordPolicy> {
|
||||
return apiFetch({ ...settings, apiKey: "", accessToken: "" }, "/api/v1/auth/password/policy", { cache: "no-store" });
|
||||
}
|
||||
|
||||
export function changePassword(settings: ApiSettings, currentPassword: string, newPassword: string): Promise<LoginResponse> {
|
||||
return apiFetch(settings, "/api/v1/auth/password/change", {
|
||||
method: "POST", body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||
});
|
||||
}
|
||||
|
||||
export function issuePasswordRecovery(settings: ApiSettings, accountId: string, currentPassword: string, identityVerified: true): Promise<PasswordRecoveryCode> {
|
||||
return apiFetch(settings, `/api/v1/auth/password/recovery/${encodeURIComponent(accountId)}`, {
|
||||
method: "POST", body: JSON.stringify({ current_password: currentPassword, identity_verified: identityVerified })
|
||||
});
|
||||
}
|
||||
|
||||
export function recoverPassword(settings: ApiSettings, email: string, recoveryCode: string, newPassword: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch({ ...settings, apiKey: "", accessToken: "" }, "/api/v1/auth/password/recover", {
|
||||
method: "POST", body: JSON.stringify({ email, recovery_code: recoveryCode, new_password: newPassword })
|
||||
});
|
||||
}
|
||||
|
||||
const passwordErrors: Record<string, string> = {
|
||||
current_password_invalid: "i18n:govoplan-access.password.current_invalid",
|
||||
invalid_new_password: "i18n:govoplan-access.password.invalid_new",
|
||||
password_unchanged: "i18n:govoplan-access.password.unchanged",
|
||||
password_recovery_disabled: "i18n:govoplan-access.password.recovery_disabled",
|
||||
password_rate_limited: "i18n:govoplan-access.password.rate_limited",
|
||||
local_password_unavailable: "i18n:govoplan-access.password.local_only",
|
||||
recovery_invalid: "i18n:govoplan-access.password.recovery_invalid",
|
||||
recovery_issuer_required: "i18n:govoplan-access.password.issuer_required",
|
||||
recovery_membership_required: "i18n:govoplan-access.password.membership_required",
|
||||
password_changed_concurrently: "i18n:govoplan-access.password.changed_concurrently",
|
||||
password_change_required: "i18n:govoplan-access.password.required"
|
||||
};
|
||||
|
||||
export function passwordErrorMessage(error: unknown): string {
|
||||
if (isApiError(error)) {
|
||||
try {
|
||||
const code: unknown = JSON.parse(error.body)?.detail?.code;
|
||||
if (typeof code === "string" && Object.prototype.hasOwnProperty.call(passwordErrors, code)) return passwordErrors[code];
|
||||
} catch { /* Never display arbitrary response content from a secret-bearing request. */ }
|
||||
if (error.status === 401) return "i18n:govoplan-access.password.session_expired";
|
||||
if (error.status === 403) return "i18n:govoplan-access.password.not_allowed";
|
||||
if (error.status === 422) return "i18n:govoplan-access.password.invalid_fields";
|
||||
if (error.status === 429) return "i18n:govoplan-access.password.rate_limited";
|
||||
}
|
||||
return "i18n:govoplan-access.password.request_failed";
|
||||
}
|
||||
@@ -358,6 +358,7 @@ export default function AdminPage({
|
||||
{!contributedSection && active === "system-users" && (
|
||||
<SystemUsersPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
canCreate={hasScope(auth, "system:accounts:create")}
|
||||
canUpdate={hasScope(auth, "system:accounts:update")}
|
||||
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
||||
|
||||
@@ -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 { 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 { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -153,7 +153,7 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} helpContextId="access.admin.api-keys" helpModuleId="access" actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} helpContextId="access.api-keys.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} /><Button helpContextId="access.api-keys.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={openCreate} disabled={!canCreate || !users.length} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : !users.length ? ACCESS_INTERFACE_I18N.selectUserAndScopes : undefined} /></>}>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" titleHelp={<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />} description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} helpContextId="access.admin.api-keys" helpModuleId="access" actions={<><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} helpContextId="access.api-keys.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} /><Button helpContextId="access.api-keys.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={openCreate} disabled={!canCreate || !users.length} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : !users.length ? ACCESS_INTERFACE_I18N.selectUserAndScopes : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
|
||||
@@ -118,10 +118,10 @@ export default function CredentialEnvelopesPanel({
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title={scopeTitle(scopeType)}
|
||||
titleHelp={<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} />}
|
||||
description={scopeDescription(scopeType)}
|
||||
loading={loadingTargets}
|
||||
error={targetError}
|
||||
actions={<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} />}
|
||||
>
|
||||
<CredentialEnvelopeManager
|
||||
settings={settings}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "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 {
|
||||
createExternalFunctionRoleMapping,
|
||||
deleteExternalFunctionRoleMapping,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ExternalFunctionRoleMappingItem,
|
||||
type RoleSummary
|
||||
} 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 { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
@@ -271,13 +271,13 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.function_role_mappings.2b64e9c3"
|
||||
titleHelp={<DocumentationHelpLink reference={FUNCTION_MAPPING_DOCUMENTATION} />}
|
||||
description="i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={
|
||||
<>
|
||||
<DocumentationHelpLink reference={FUNCTION_MAPPING_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button>
|
||||
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : !assignableRoles.length ? ACCESS_INTERFACE_I18N.selectAssignableRole : undefined} />
|
||||
</>
|
||||
|
||||
@@ -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 { 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 { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -146,7 +146,7 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" titleHelp={<DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} />} description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
|
||||
@@ -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 { 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 { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -135,7 +135,7 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" titleHelp={<DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} />} description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
|
||||
@@ -445,6 +445,7 @@ export default function ServiceAccountsPanel({
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="Service accounts"
|
||||
titleHelp={<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />}
|
||||
description="Manage non-login automation principals and their independently rotatable, scope-bounded credentials."
|
||||
loading={loading}
|
||||
error={error}
|
||||
@@ -452,7 +453,6 @@ export default function ServiceAccountsPanel({
|
||||
helpContextId="access.admin.service-accounts"
|
||||
helpModuleId="access"
|
||||
actions={<>
|
||||
<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />
|
||||
<Button helpContextId="access.service-accounts.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading}>Reload</Button>
|
||||
<AdminIconButton label="Add service account" icon={<Plus />} variant="primary" helpContextId="access.service-accounts.action.create" helpModuleId="access" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} />
|
||||
</>}
|
||||
|
||||
@@ -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 { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import {
|
||||
createSystemRole,
|
||||
deleteSystemRole,
|
||||
@@ -238,11 +238,12 @@ export default function SystemRolesPanel({
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.system_roles.a9461aa6"
|
||||
titleHelp={<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />}
|
||||
description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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 { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
||||
import { Search, Pencil, Plus, Trash2, KeyRound } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { hasScope } from "@govoplan/core-webui";
|
||||
import PasswordRecoveryIssueDialog from "../passwords/PasswordRecoveryIssueDialog";
|
||||
import { usePasswordPolicy } from "../passwords/usePasswordPolicy";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -36,6 +39,7 @@ const emptyDraft = {
|
||||
|
||||
export default function SystemUsersPanel({
|
||||
settings,
|
||||
auth,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
@@ -50,7 +54,7 @@ export default function SystemUsersPanel({
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
@@ -61,6 +65,11 @@ export default function SystemUsersPanel({
|
||||
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
|
||||
const [recovering, setRecovering] = useState<SystemAccountItem | null>(null);
|
||||
const { policy: passwordPolicy } = usePasswordPolicy(settings);
|
||||
const canIssueRecovery = Boolean(passwordPolicy?.recovery_enabled
|
||||
&& auth.principal?.auth_method === "session" && auth.user.local_password
|
||||
&& hasScope(auth, "system:*") && !auth.user.required_auth_action);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -216,20 +225,25 @@ export default function SystemUsersPanel({
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
...(canIssueRecovery && row.local_password === true && row.is_active
|
||||
? [{ id: "recover-password", label: "i18n:govoplan-access.password.issue_title", icon: <KeyRound />, helpContextId: "access.password.issue-recovery", helpModuleId: "access", onClick: () => setRecovering(row) }]
|
||||
: []),
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate, canIssueRecovery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{recovering && canIssueRecovery && <PasswordRecoveryIssueDialog key={recovering.account_id} settings={settings} account={recovering} onClose={() => setRecovering(null)} />}
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.central_users.91ac1b51"
|
||||
titleHelp={<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />}
|
||||
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
|
||||
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
@@ -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 { 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 { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -276,7 +276,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" titleHelp={<DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} />} description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage,
|
||||
type ApiSettings, type AuthInfo, type AuthUpdate
|
||||
} from "@govoplan/core-webui";
|
||||
import { changePassword, passwordErrorMessage } from "../../api/passwords";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordChangePanel({ settings, auth, onAuthChange }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const required = auth.user.required_auth_action === "change_password";
|
||||
const localSession = auth.principal?.auth_method === "session" && auth.user.local_password === true;
|
||||
const complete = Boolean(policy && currentPassword && Array.from(currentPassword).length <= 1024 && newPassword === confirmation
|
||||
&& Array.from(newPassword).length >= policy.min_length
|
||||
&& Array.from(newPassword).length <= policy.max_length);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !complete || !localSession) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
try {
|
||||
const response = await changePassword(settings, currentPassword, newPassword);
|
||||
onAuthChange(response, "");
|
||||
setSuccess(true);
|
||||
} catch (reason) {
|
||||
setError(passwordErrorMessage(reason));
|
||||
} finally {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmation("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <section>
|
||||
<h1>{required ? "i18n:govoplan-access.password.required_title" : "i18n:govoplan-access.password.change_title"}</h1>
|
||||
{required && <p>i18n:govoplan-access.password.required</p>}
|
||||
{!localSession ? <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.local_only</DismissibleAlert> : <>
|
||||
<p>i18n:govoplan-access.password.change_consequences</p>
|
||||
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.change" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success">i18n:govoplan-access.password.changed</DismissibleAlert>}
|
||||
<FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
|
||||
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={currentPassword} onValueChange={setCurrentPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-access.password.new" help={policy ? i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length }) : undefined} helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={newPassword} onValueChange={setNewPassword} autoComplete="new-password" minLength={policy?.min_length} maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} generator />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} />
|
||||
</FormField>
|
||||
{confirmation && newPassword !== confirmation && <p role="status">i18n:govoplan-access.password.mismatch</p>}
|
||||
<Button type="submit" variant="primary" disabled={busy || !complete} helpContextId="access.password.change" helpModuleId="access"
|
||||
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !complete ? "i18n:govoplan-access.password.complete_fields" : undefined}>
|
||||
{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.change_title"}
|
||||
</Button>
|
||||
</FormLayout>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Link } from "react-router";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordLoginHelp({ settings, onNavigate }: { settings: ApiSettings; onNavigate: () => void }) {
|
||||
const { policy } = usePasswordPolicy(settings);
|
||||
return policy?.recovery_enabled
|
||||
? <p><Link to="/password-recovery" onClick={onNavigate} data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.forgot</Link></p>
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button, Dialog, DismissibleAlert, FormField, FormLayout, PasswordField,
|
||||
i18nMessage, usePlatformLanguage, type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import { issuePasswordRecovery, passwordErrorMessage, type PasswordRecoveryCode } from "../../api/passwords";
|
||||
|
||||
export default function PasswordRecoveryIssueDialog({ settings, account, onClose }: {
|
||||
settings: ApiSettings;
|
||||
account: { account_id: string; email: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const formId = useId();
|
||||
const [password, setPassword] = useState("");
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [result, setResult] = useState<PasswordRecoveryCode | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ready = verified && Boolean(password) && Array.from(password).length <= 1024;
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !ready || result) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setResult(await issuePasswordRecovery(settings, account.account_id, password, true));
|
||||
} catch (reason) { setError(passwordErrorMessage(reason)); }
|
||||
finally {
|
||||
setPassword("");
|
||||
setVerified(false);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <Dialog open variant="administration" size="large"
|
||||
title="i18n:govoplan-access.password.issue_title" onClose={() => { if (!busy) onClose(); }}
|
||||
footer={<>
|
||||
<Button onClick={onClose} disabled={busy} helpContextId="access.password.issue-recovery" helpModuleId="access">{result ? "i18n:govoplan-access.close.bbfa773e" : "i18n:govoplan-access.cancel.77dfd213"}</Button>
|
||||
{!result && <Button type="submit" form={formId} variant="primary" disabled={busy || !ready} helpContextId="access.password.issue-recovery" helpModuleId="access"
|
||||
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.issue_requirements" : undefined}>
|
||||
i18n:govoplan-access.password.issue_title
|
||||
</Button>}
|
||||
</>}>
|
||||
<p>{i18nMessage("i18n:govoplan-access.password.issue_for", { value0: account.email })}</p>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{result ? <>
|
||||
<p>i18n:govoplan-access.password.code_once</p>
|
||||
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.issue-recovery" helpModuleId="access"><input value={result.recovery_code} readOnly autoComplete="off" /></FormField>
|
||||
<p>{i18nMessage("i18n:govoplan-access.password.code_expires", { value0: new Date(result.expires_at).toLocaleString() })}</p>
|
||||
<p>i18n:govoplan-access.password.code_delivery</p>
|
||||
</> : <FormLayout columns={1} collapseAt="standard" id={formId} onSubmit={submit}>
|
||||
<p>i18n:govoplan-access.password.issue_consequences</p>
|
||||
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.issue-recovery" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={password} onValueChange={setPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} /></FormField>
|
||||
<label className="checkbox-field" data-help-context-id="access.password.issue-recovery" data-help-module-id="access"><input type="checkbox" checked={verified} onChange={(event) => setVerified(event.target.checked)} disabled={busy} required /> <span>i18n:govoplan-access.password.identity_verified</span></label>
|
||||
</FormLayout>}
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage, type ApiSettings } from "@govoplan/core-webui";
|
||||
import { passwordErrorMessage, recoverPassword } from "../../api/passwords";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordRecoveryPage({ settings }: { settings: ApiSettings }) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [complete, setComplete] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ready = Boolean(policy?.recovery_enabled && email.trim() && code.trim()
|
||||
&& password === confirmation && Array.from(password).length >= policy.min_length
|
||||
&& Array.from(password).length <= policy.max_length);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !ready) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await recoverPassword(settings, email.trim(), code.trim(), password);
|
||||
setComplete(true);
|
||||
setEmail("");
|
||||
} catch (reason) { setError(passwordErrorMessage(reason)); }
|
||||
finally {
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setConfirmation("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="public-landing auth-action-page"><section className="public-card">
|
||||
<h1>i18n:govoplan-access.password.recover_title</h1>
|
||||
{complete ? <DismissibleAlert tone="success" dismissible={false}>i18n:govoplan-access.password.recovered</DismissibleAlert> : <>
|
||||
<p>i18n:govoplan-access.password.recovery_instructions</p>
|
||||
<p>i18n:govoplan-access.password.recovery_consequences</p>
|
||||
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.recover" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
|
||||
{policy && !policy.recovery_enabled && <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.recovery_disabled</DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{policy?.recovery_enabled && <FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
|
||||
<FormField label="i18n:govoplan-access.email.84add5b2" helpContextId="access.password.recover" helpModuleId="access"><input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required maxLength={320} disabled={busy} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.recovery_code")} value={code} onValueChange={setCode} autoComplete="off" maxLength={256} required disabled={busy} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.new" help={i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length })} helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={password} onValueChange={setPassword} autoComplete="new-password" minLength={policy.min_length} maxLength={2 * policy.max_length} required disabled={busy} generator /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * policy.max_length} required disabled={busy} /></FormField>
|
||||
{confirmation && confirmation !== password && <p role="status">i18n:govoplan-access.password.mismatch</p>}
|
||||
<Button type="submit" variant="primary" helpContextId="access.password.recover" helpModuleId="access" disabled={busy || !ready} disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.complete_fields" : undefined}>{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.recover_title"}</Button>
|
||||
</FormLayout>}
|
||||
</>}
|
||||
<p><Link to="/" data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.return_sign_in</Link></p>
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { fetchPasswordPolicy, passwordErrorMessage, type PasswordPolicy } from "../../api/passwords";
|
||||
|
||||
export function usePasswordPolicy(settings: ApiSettings) {
|
||||
const [policy, setPolicy] = useState<PasswordPolicy | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, reload] = useState(0);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setPolicy(null);
|
||||
setError("");
|
||||
fetchPasswordPolicy(settings).then((value) => {
|
||||
if (current) setPolicy(value);
|
||||
}).catch((reason) => {
|
||||
if (current) setError(passwordErrorMessage(reason));
|
||||
});
|
||||
return () => { current = false; };
|
||||
}, [settings.apiBaseUrl, revision]);
|
||||
return { policy, error, reload: () => reload((value) => value + 1) };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const passwordTranslations: PlatformTranslations = {
|
||||
en: {
|
||||
"i18n:govoplan-access.password.change_title": "Change password",
|
||||
"i18n:govoplan-access.password.required_title": "Change your initial password",
|
||||
"i18n:govoplan-access.password.required": "Set a new password before opening your workspace. Enter the current or initial password you used to sign in.",
|
||||
"i18n:govoplan-access.password.local_only": "Password changes require an interactive session for a local account. External accounts must use their identity provider.",
|
||||
"i18n:govoplan-access.password.change_consequences": "Changing your password ends all other browser sessions and revokes all account API keys. This browser receives a new session. Update integrations with newly issued API keys afterwards. Unused recovery codes for this account and codes it issued for other people also become invalid.",
|
||||
"i18n:govoplan-access.password.changed": "Your password was changed. Other sessions ended and account API keys were revoked.",
|
||||
"i18n:govoplan-access.password.new": "New password",
|
||||
"i18n:govoplan-access.password.confirm": "Confirm new password",
|
||||
"i18n:govoplan-access.password.length": "Use between {value0} and {value1} characters.",
|
||||
"i18n:govoplan-access.password.mismatch": "The new passwords do not match.",
|
||||
"i18n:govoplan-access.password.complete_fields": "Complete the required fields and enter matching new passwords of the required length.",
|
||||
"i18n:govoplan-access.password.saving": "Updating password…",
|
||||
"i18n:govoplan-access.password.current_invalid": "Your current password was not accepted. Enter it again to authorize this action.",
|
||||
"i18n:govoplan-access.password.invalid_new": "Use a new password between 10 and 1024 characters.",
|
||||
"i18n:govoplan-access.password.unchanged": "Choose a password different from your current password.",
|
||||
"i18n:govoplan-access.password.recovery_disabled": "Administrator-assisted password recovery is not enabled. Contact your administrator for help.",
|
||||
"i18n:govoplan-access.password.rate_limited": "Too many attempts. Wait before trying again.",
|
||||
"i18n:govoplan-access.password.recovery_invalid": "This recovery code is invalid, expired, already used, or no longer authorized. Ask your System owner for a new code.",
|
||||
"i18n:govoplan-access.password.issuer_required": "Only a current System owner can issue a recovery code.",
|
||||
"i18n:govoplan-access.password.membership_required": "This account needs an active tenant membership before password recovery is available.",
|
||||
"i18n:govoplan-access.password.changed_concurrently": "The account password changed during this operation. Sign in again before continuing.",
|
||||
"i18n:govoplan-access.password.session_expired": "Your session is no longer valid. Sign in again to continue.",
|
||||
"i18n:govoplan-access.password.not_allowed": "This password operation is not permitted for the current account or session.",
|
||||
"i18n:govoplan-access.password.invalid_fields": "Check the required fields and password length, then enter your credentials again.",
|
||||
"i18n:govoplan-access.password.request_failed": "The password service could not complete the request. Check your connection and try again.",
|
||||
"i18n:govoplan-access.password.forgot": "Forgot your password?",
|
||||
"i18n:govoplan-access.password.recover_title": "Recover local password",
|
||||
"i18n:govoplan-access.password.recovery_instructions": "Contact a System owner to verify your identity independently and receive a recovery code. Enter your account email and that code below. GovOPlaN does not send a recovery email.",
|
||||
"i18n:govoplan-access.password.recovery_consequences": "Successful recovery ends all browser sessions and revokes all account API keys. Unused recovery codes for this account and codes it issued for other people also become invalid. You must then sign in with the new password.",
|
||||
"i18n:govoplan-access.password.recovered": "Your password was replaced and existing sessions and API keys were revoked. Sign in with your new password.",
|
||||
"i18n:govoplan-access.password.recovery_code": "Recovery code",
|
||||
"i18n:govoplan-access.password.return_sign_in": "Return to sign in",
|
||||
"i18n:govoplan-access.password.issue_title": "Issue recovery code",
|
||||
"i18n:govoplan-access.password.issue_for": "Recover access for {value0}.",
|
||||
"i18n:govoplan-access.password.issue_requirements": "Enter your current password and confirm that you independently verified this person's identity.",
|
||||
"i18n:govoplan-access.password.identity_verified": "I independently verified this person's identity outside GovOPlaN.",
|
||||
"i18n:govoplan-access.password.issue_consequences": "Issuing a code replaces earlier unused recovery codes. When used, it replaces the account password, ends every browser session, and revokes all account API keys. Codes the target account issued for other people also become invalid. Verify the account holder before proceeding.",
|
||||
"i18n:govoplan-access.password.code_once": "This code is shown once. It can be used once before its expiry.",
|
||||
"i18n:govoplan-access.password.code_expires": "Expires: {value0}",
|
||||
"i18n:govoplan-access.password.code_delivery": "Give the code only to the verified account holder through your agreed confidential channel. Direct them to Password recovery from the sign-in screen. Closing this dialog clears the code from this screen."
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-access.password.change_title": "Passwort ändern",
|
||||
"i18n:govoplan-access.password.required_title": "Initiales Passwort ändern",
|
||||
"i18n:govoplan-access.password.required": "Legen Sie ein neues Passwort fest, bevor Sie den Arbeitsbereich öffnen. Geben Sie das aktuelle oder initiale Passwort ein, mit dem Sie sich angemeldet haben.",
|
||||
"i18n:govoplan-access.password.local_only": "Passwortänderungen benötigen eine interaktive Sitzung für ein lokales Konto. Externe Konten verwenden ihren Identitätsanbieter.",
|
||||
"i18n:govoplan-access.password.change_consequences": "Die Passwortänderung beendet alle anderen Browsersitzungen und widerruft sämtliche API-Schlüssel des Kontos. Dieser Browser erhält eine neue Sitzung. Aktualisieren Sie anschließend Integrationen mit neu ausgestellten API-Schlüsseln. Ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes werden ebenfalls ungültig.",
|
||||
"i18n:govoplan-access.password.changed": "Ihr Passwort wurde geändert. Andere Sitzungen wurden beendet und die API-Schlüssel des Kontos widerrufen.",
|
||||
"i18n:govoplan-access.password.new": "Neues Passwort",
|
||||
"i18n:govoplan-access.password.confirm": "Neues Passwort bestätigen",
|
||||
"i18n:govoplan-access.password.length": "Verwenden Sie zwischen {value0} und {value1} Zeichen.",
|
||||
"i18n:govoplan-access.password.mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||
"i18n:govoplan-access.password.complete_fields": "Füllen Sie die Pflichtfelder aus und geben Sie übereinstimmende neue Passwörter der erforderlichen Länge ein.",
|
||||
"i18n:govoplan-access.password.saving": "Passwort wird aktualisiert…",
|
||||
"i18n:govoplan-access.password.current_invalid": "Ihr aktuelles Passwort wurde nicht akzeptiert. Geben Sie es erneut ein, um diese Aktion zu autorisieren.",
|
||||
"i18n:govoplan-access.password.invalid_new": "Verwenden Sie ein neues Passwort mit 10 bis 1024 Zeichen.",
|
||||
"i18n:govoplan-access.password.unchanged": "Wählen Sie ein anderes Passwort als Ihr aktuelles Passwort.",
|
||||
"i18n:govoplan-access.password.recovery_disabled": "Die administrativ unterstützte Passwortwiederherstellung ist nicht aktiviert. Wenden Sie sich an Ihre Administration.",
|
||||
"i18n:govoplan-access.password.rate_limited": "Zu viele Versuche. Warten Sie, bevor Sie es erneut versuchen.",
|
||||
"i18n:govoplan-access.password.recovery_invalid": "Dieser Wiederherstellungscode ist ungültig, abgelaufen, bereits verwendet oder nicht mehr autorisiert. Bitten Sie den Systemverantwortlichen um einen neuen Code.",
|
||||
"i18n:govoplan-access.password.issuer_required": "Nur ein aktueller Systemverantwortlicher darf einen Wiederherstellungscode ausstellen.",
|
||||
"i18n:govoplan-access.password.membership_required": "Das Konto benötigt vor einer Passwortwiederherstellung eine aktive Mandantenmitgliedschaft.",
|
||||
"i18n:govoplan-access.password.changed_concurrently": "Das Kontopasswort wurde während dieses Vorgangs geändert. Melden Sie sich erneut an, bevor Sie fortfahren.",
|
||||
"i18n:govoplan-access.password.session_expired": "Ihre Sitzung ist nicht mehr gültig. Melden Sie sich erneut an, um fortzufahren.",
|
||||
"i18n:govoplan-access.password.not_allowed": "Dieser Passwortvorgang ist für das aktuelle Konto oder die aktuelle Sitzung nicht erlaubt.",
|
||||
"i18n:govoplan-access.password.invalid_fields": "Prüfen Sie Pflichtfelder und Passwortlänge und geben Sie Ihre Zugangsdaten erneut ein.",
|
||||
"i18n:govoplan-access.password.request_failed": "Der Passwortdienst konnte die Anfrage nicht abschließen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
||||
"i18n:govoplan-access.password.forgot": "Passwort vergessen?",
|
||||
"i18n:govoplan-access.password.recover_title": "Lokales Passwort wiederherstellen",
|
||||
"i18n:govoplan-access.password.recovery_instructions": "Wenden Sie sich an einen Systemverantwortlichen, um Ihre Identität unabhängig prüfen zu lassen und einen Wiederherstellungscode zu erhalten. Geben Sie unten Ihre Konto-E-Mail-Adresse und diesen Code ein. GovOPlaN versendet keine Wiederherstellungs-E-Mail.",
|
||||
"i18n:govoplan-access.password.recovery_consequences": "Eine erfolgreiche Wiederherstellung beendet alle Browsersitzungen und widerruft sämtliche API-Schlüssel des Kontos. Ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes werden ebenfalls ungültig. Melden Sie sich anschließend mit dem neuen Passwort an.",
|
||||
"i18n:govoplan-access.password.recovered": "Ihr Passwort wurde ersetzt. Bestehende Sitzungen und API-Schlüssel wurden widerrufen. Melden Sie sich mit Ihrem neuen Passwort an.",
|
||||
"i18n:govoplan-access.password.recovery_code": "Wiederherstellungscode",
|
||||
"i18n:govoplan-access.password.return_sign_in": "Zurück zur Anmeldung",
|
||||
"i18n:govoplan-access.password.issue_title": "Wiederherstellungscode ausstellen",
|
||||
"i18n:govoplan-access.password.issue_for": "Zugriff für {value0} wiederherstellen.",
|
||||
"i18n:govoplan-access.password.issue_requirements": "Geben Sie Ihr aktuelles Passwort ein und bestätigen Sie die unabhängige Prüfung der Identität dieser Person.",
|
||||
"i18n:govoplan-access.password.identity_verified": "Ich habe die Identität dieser Person unabhängig außerhalb von GovOPlaN geprüft.",
|
||||
"i18n:govoplan-access.password.issue_consequences": "Ein neuer Code ersetzt frühere unbenutzte Wiederherstellungscodes. Seine Verwendung ersetzt das Kontopasswort, beendet jede Browsersitzung und widerruft sämtliche API-Schlüssel des Kontos. Vom Zielkonto für andere Personen ausgestellte Codes werden ebenfalls ungültig. Prüfen Sie vorab die Identität des Kontoinhabers.",
|
||||
"i18n:govoplan-access.password.code_once": "Dieser Code wird einmal angezeigt. Er kann vor seinem Ablauf einmal verwendet werden.",
|
||||
"i18n:govoplan-access.password.code_expires": "Gültig bis: {value0}",
|
||||
"i18n:govoplan-access.password.code_delivery": "Übermitteln Sie den Code ausschließlich dem verifizierten Kontoinhaber über den vereinbarten vertraulichen Kanal. Verweisen Sie auf die Passwortwiederherstellung im Anmeldebildschirm. Beim Schließen dieses Dialogs wird der Code aus dieser Ansicht entfernt."
|
||||
}
|
||||
};
|
||||
+22
-4
@@ -1,15 +1,19 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||
import type { ActingContextRuntimeUiCapability, AuthActionUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||
import { adminReadScopes } from "@govoplan/core-webui";
|
||||
import ActingContextSelector from "./features/acting-context/ActingContextSelector";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import { passwordTranslations } from "./i18n/passwordTranslations";
|
||||
|
||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||
const SessionSettingsPanel = lazy(() => import("./features/sessions/SessionSettingsPanel"));
|
||||
const PasswordChangePanel = lazy(() => import("./features/passwords/PasswordChangePanel"));
|
||||
const PasswordRecoveryPage = lazy(() => import("./features/passwords/PasswordRecoveryPage"));
|
||||
const PasswordLoginHelp = lazy(() => import("./features/passwords/PasswordLoginHelp"));
|
||||
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
en: { ...generatedTranslations.en, ...passwordTranslations.en },
|
||||
de: { ...generatedTranslations.de, ...passwordTranslations.de }
|
||||
};
|
||||
|
||||
const accessAdminSurfaces = [
|
||||
@@ -26,11 +30,21 @@ const accessAdminSurfaces = [
|
||||
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 },
|
||||
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 },
|
||||
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 },
|
||||
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 }
|
||||
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 },
|
||||
{ id: "access.settings.password", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.password.change_title", order: 21 }
|
||||
];
|
||||
|
||||
const accessSettingsSections: SettingsSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "password",
|
||||
surfaceId: "access.settings.password",
|
||||
label: "i18n:govoplan-access.password.change_title",
|
||||
group: "account",
|
||||
order: 21,
|
||||
render: ({ settings, auth, onAuthChange }) => onAuthChange
|
||||
? createElement(PasswordChangePanel, { settings, auth, onAuthChange }) : null
|
||||
},
|
||||
{
|
||||
id: "sessions",
|
||||
surfaceId: "access.settings.sessions",
|
||||
@@ -61,7 +75,11 @@ export const accessModule: PlatformWebModule = {
|
||||
|
||||
routes: [
|
||||
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }],
|
||||
publicRoutes: [
|
||||
{ path: "/password-recovery", render: ({ settings }) => createElement(PasswordRecoveryPage, { settings }) }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"auth.actions": { actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp } satisfies AuthActionUiCapability,
|
||||
"access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability,
|
||||
"settings.sections": accessSettingsSections
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user