Compare commits
5 Commits
c4b90181e0
...
b89a2d15f1
| Author | SHA1 | Date | |
|---|---|---|---|
| b89a2d15f1 | |||
| 7f923afdad | |||
| a7683c5d4a | |||
| 41ad057f7e | |||
| bf0729eb59 |
@@ -453,6 +453,18 @@ The manifest should declare:
|
|||||||
- navigation metadata using serializable icon names
|
- navigation metadata using serializable icon names
|
||||||
- uninstall guard providers for data, migration, worker, or scheduler vetoes
|
- uninstall guard providers for data, migration, worker, or scheduler vetoes
|
||||||
|
|
||||||
|
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
|
||||||
|
only when every authenticated tenant member must receive that narrow baseline
|
||||||
|
while the contributing module is installed. Access derives the explicit grant
|
||||||
|
from the active manifest set during authorization without mutating the request
|
||||||
|
transaction. It may materialize a non-assignable role row for administration,
|
||||||
|
but no per-user assignment is required and role edits cannot remove the
|
||||||
|
baseline.
|
||||||
|
This is not a shortcut for feature authorization: keep the template narrow and
|
||||||
|
continue to enforce each domain action's own permission and resource policy.
|
||||||
|
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
|
||||||
|
templates are rejected by registry validation.
|
||||||
|
|
||||||
Backend nav metadata must use icon-name strings, not frontend components:
|
Backend nav metadata must use icon-name strings, not frontend components:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ The trust layer should provide:
|
|||||||
- key rotation and epoch tracking
|
- key rotation and epoch tracking
|
||||||
- recovery policy hooks
|
- recovery policy hooks
|
||||||
|
|
||||||
|
Recovery must be organizationally governed. A server-held universal plaintext
|
||||||
|
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
|
||||||
|
grant needs an explicit assurance profile, authority/quorum, audit trail, and
|
||||||
|
user-visible consequence.
|
||||||
|
|
||||||
## Role And Function Postboxes
|
## Role And Function Postboxes
|
||||||
|
|
||||||
Role-bound access needs special handling. A postbox can be bound to an
|
Role-bound access needs special handling. A postbox can be bound to an
|
||||||
@@ -75,6 +80,30 @@ rewrapping service:
|
|||||||
Key epochs are required when role membership changes. Older messages may remain
|
Key epochs are required when role membership changes. Older messages may remain
|
||||||
readable according to policy, but new access must use the current epoch.
|
readable according to policy, but new access must use the current epoch.
|
||||||
|
|
||||||
|
The function-bound container exists independently of membership. It may remain
|
||||||
|
vacant and continue to receive ciphertext without falling back to an unrelated
|
||||||
|
personal mailbox. Zero, one, or several incumbents are valid states. Each
|
||||||
|
incumbent receives an independently auditable, device-bound wrapped-key path;
|
||||||
|
the postbox is never copied into their account ownership.
|
||||||
|
|
||||||
|
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
|
||||||
|
encryption permits the normal rotation path to rewrap per-message data keys
|
||||||
|
rather than rewrite large ciphertext objects; a security policy may require
|
||||||
|
full content re-encryption for selected compromise or cryptographic-profile
|
||||||
|
events. The history available to a new incumbent must be selected policy (all
|
||||||
|
retained history, a bounded historical window, or assignment-time content) and
|
||||||
|
recorded with the grant.
|
||||||
|
|
||||||
|
Delegation is a time-bounded represented-function grant, not a copy or
|
||||||
|
substitution of the postbox. Expiry or withdrawal stops future key release and
|
||||||
|
actions. It cannot revoke plaintext already decrypted, printed, exported, or
|
||||||
|
captured outside the platform. Multiple simultaneous incumbents and delegates
|
||||||
|
remain distinguishable in key-fetch and action evidence.
|
||||||
|
|
||||||
|
Postbox content and signed manifests are immutable. Correction or replacement
|
||||||
|
creates a linked new object/version; it never silently substitutes ciphertext
|
||||||
|
or evidence that another actor may already have inspected.
|
||||||
|
|
||||||
## External Recipients
|
## External Recipients
|
||||||
|
|
||||||
External recipients may need one-time or time-limited access without a full
|
External recipients may need one-time or time-limited access without a full
|
||||||
|
|||||||
21
docs/THROTTLING.md
Normal file
21
docs/THROTTLING.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Shared fixed-window throttling
|
||||||
|
|
||||||
|
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||||
|
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||||
|
become store keys. A configured Redis instance provides atomic counters shared
|
||||||
|
across API workers; development, a missing Redis configuration, and temporary
|
||||||
|
Redis outages use a bounded process-local fallback. When Redis fails, local
|
||||||
|
attempts are still mirrored so losing the distributed store does not reset the
|
||||||
|
active worker's protection window.
|
||||||
|
|
||||||
|
Callers define one or more `ThrottleDimension` values with a controlled
|
||||||
|
namespace, a subject and a positive limit. They must call `check` before an
|
||||||
|
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
|
||||||
|
dimension after successful verification. A blocked decision includes a
|
||||||
|
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
|
||||||
|
|
||||||
|
The first consumer is Scheduling's anonymous participation password challenge.
|
||||||
|
Its namespace is `poll-participation-password`; its subject combines tenant,
|
||||||
|
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
|
||||||
|
login throttle predates this primitive and should be migrated onto it in a
|
||||||
|
separate compatibility-preserving slice.
|
||||||
@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
||||||
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
||||||
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
||||||
|
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
|
||||||
|
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||||
|
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||||
|
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||||
|
|
||||||
## Confirmed Implementation Decisions
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
@@ -191,9 +195,10 @@ explicitly retains the exception.
|
|||||||
Decision: Scheduling requests provide a concrete reference application of the
|
Decision: Scheduling requests provide a concrete reference application of the
|
||||||
universal placement and component rules.
|
universal placement and component rules.
|
||||||
|
|
||||||
- The `Scheduling requests` header owns one `Add` action.
|
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||||
- Its left panel is used for the creation view only, not as a permanent second
|
requests for me`; it is list context, not a second creation affordance.
|
||||||
creation affordance.
|
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
|
||||||
|
the shared view/create/edit surface in the right main panel.
|
||||||
- Basic information, Calendar integration, candidate slots, and participants
|
- Basic information, Calendar integration, candidate slots, and participants
|
||||||
use the central `Card` component as four logical sections.
|
use the central `Card` component as four logical sections.
|
||||||
- Candidate slots and participants use the central `DataGrid`, including its
|
- Candidate slots and participants use the central `DataGrid`, including its
|
||||||
@@ -207,6 +212,50 @@ universal placement and component rules.
|
|||||||
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||||
not Scheduling-local component variants.
|
not Scheduling-local component variants.
|
||||||
|
|
||||||
|
### DUE-011: Field Help, Discard, And Table Action Contracts
|
||||||
|
|
||||||
|
Decision: the central components own these interactions; modules compose them
|
||||||
|
instead of reproducing their behavior.
|
||||||
|
|
||||||
|
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
|
||||||
|
compositions use `FieldLabel` explicitly when the meaning or limitation is
|
||||||
|
not self-explanatory.
|
||||||
|
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||||
|
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||||
|
applying `ui-hide-help-hints` at the document root.
|
||||||
|
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||||
|
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||||
|
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||||
|
the same shared unsaved-changes dialog. A browser tab/window unload remains a
|
||||||
|
browser-controlled confirmation because browsers do not permit a custom
|
||||||
|
modal at that boundary.
|
||||||
|
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
|
||||||
|
`disabledReason` for row state; omit an action only when that action does not
|
||||||
|
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||||
|
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||||
|
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||||
|
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||||
|
|
||||||
|
#### FieldLabel Omission Register
|
||||||
|
|
||||||
|
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||||
|
listed here. Module repositories keep an equivalent register in their durable
|
||||||
|
UI documentation until a central cross-repository audit is available.
|
||||||
|
|
||||||
|
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||||
|
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||||
|
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||||
|
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||||
|
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
|
||||||
|
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
|
||||||
|
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
|
||||||
|
|
||||||
|
#### Alert Exception Register
|
||||||
|
|
||||||
|
No `window.alert` or global `alert` exception is authorized.
|
||||||
|
|
||||||
## Implementation Sequence
|
## Implementation Sequence
|
||||||
|
|
||||||
| Phase | Scope | Output |
|
| Phase | Scope | Output |
|
||||||
@@ -277,6 +326,14 @@ Every new or changed admin/configuration surface should answer:
|
|||||||
- Are logical sections, tabular collections, boolean settings, and repeatable
|
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||||
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||||
structured row per person respectively?
|
structured row per person respectively?
|
||||||
|
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
|
||||||
|
recorded with its rationale and accessible-name source?
|
||||||
|
- Do explicit Discard and dirty navigation use the shared unsaved-changes
|
||||||
|
registration/dialog rather than a page-local confirmation?
|
||||||
|
- Does every row retain the table's action set in the same order, disabling
|
||||||
|
unavailable actions and reserving the same empty-row slots?
|
||||||
|
- Is feedback rendered with a central dialog/alert component, with no
|
||||||
|
unauthorized `window.alert` or global `alert` call?
|
||||||
- If automation is involved, can the user see the trigger, system actor,
|
- If automation is involved, can the user see the trigger, system actor,
|
||||||
observed effects, and failure/manual-intervention state?
|
observed effects, and failure/manual-intervention state?
|
||||||
- Are technical details available without being the first thing the user sees?
|
- Are technical details available without being the first thing the user sees?
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.9"
|
version = "0.1.10"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class RoleTemplate:
|
|||||||
level: PermissionLevel = "tenant"
|
level: PermissionLevel = "tenant"
|
||||||
managed: bool = True
|
managed: bool = True
|
||||||
protected: bool = False
|
protected: bool = False
|
||||||
|
default_authenticated: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -320,9 +320,34 @@ def _validate_role_template_scopes(
|
|||||||
*,
|
*,
|
||||||
known_scopes: set[str],
|
known_scopes: set[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
seen_templates: dict[tuple[str, str], str] = {}
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
for template in manifest.role_templates:
|
for template in manifest.role_templates:
|
||||||
|
template_key = (template.level, template.slug)
|
||||||
|
previous_module = seen_templates.get(template_key)
|
||||||
|
if previous_module is not None:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate {template.level} role template slug {template.slug!r} "
|
||||||
|
f"in modules {previous_module!r} and {manifest.id!r}"
|
||||||
|
)
|
||||||
|
seen_templates[template_key] = manifest.id
|
||||||
|
if template.default_authenticated and template.level != "tenant":
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} must be tenant-level"
|
||||||
|
)
|
||||||
|
if template.default_authenticated and not template.managed:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} must be managed"
|
||||||
|
)
|
||||||
for scope in template.permissions:
|
for scope in template.permissions:
|
||||||
|
if template.default_authenticated and (
|
||||||
|
scope in {"*", "tenant:*", "system:*"}
|
||||||
|
or _WILDCARD_RE.match(scope)
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} "
|
||||||
|
"must use explicit permissions, not wildcard scopes"
|
||||||
|
)
|
||||||
if _role_template_scope_known(scope, known_scopes):
|
if _role_template_scope_known(scope, known_scopes):
|
||||||
continue
|
continue
|
||||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||||
|
|||||||
349
src/govoplan_core/core/throttling.py
Normal file
349
src/govoplan_core/core/throttling.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||||
|
_REDIS_INCREMENT_SCRIPT = """
|
||||||
|
local count = redis.call('INCR', KEYS[1])
|
||||||
|
if count == 1 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
local ttl = redis.call('TTL', KEYS[1])
|
||||||
|
return {count, ttl}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FixedWindowBucket:
|
||||||
|
count: int = 0
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThrottleDimension:
|
||||||
|
"""One independently limited dimension without putting its value in keys."""
|
||||||
|
|
||||||
|
namespace: str
|
||||||
|
subject: str
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThrottleDecision:
|
||||||
|
allowed: bool
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowStore(Protocol):
|
||||||
|
def read(self, key: str) -> FixedWindowBucket: ...
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryFixedWindowStore:
|
||||||
|
"""Bounded process-local store for development and distributed-store loss."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
max_entries: int = 10_000,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._entries: dict[str, tuple[int, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._max_entries = max(2, max_entries)
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
return FixedWindowBucket()
|
||||||
|
count, expires_at = entry
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
self._make_room(now=now, incoming_key=key)
|
||||||
|
count = 1
|
||||||
|
expires_at = now + window_seconds
|
||||||
|
else:
|
||||||
|
count = entry[0] + 1
|
||||||
|
expires_at = entry[1]
|
||||||
|
self._entries[key] = (count, expires_at)
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
|
||||||
|
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||||
|
entry = self._entries.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if entry[1] <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||||
|
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||||
|
return
|
||||||
|
for key, (_count, expires_at) in tuple(self._entries.items()):
|
||||||
|
if expires_at <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
while len(self._entries) >= self._max_entries:
|
||||||
|
self._entries.pop(next(iter(self._entries)))
|
||||||
|
|
||||||
|
|
||||||
|
class RedisFixedWindowStore:
|
||||||
|
"""Atomic Redis fixed-window counters shared across API workers."""
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str) -> None:
|
||||||
|
self._client = Redis.from_url(
|
||||||
|
redis_url,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=0.25,
|
||||||
|
socket_timeout=0.25,
|
||||||
|
health_check_interval=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
pipeline = self._client.pipeline(transaction=False)
|
||||||
|
pipeline.get(key)
|
||||||
|
pipeline.ttl(key)
|
||||||
|
raw_count, raw_ttl = pipeline.execute()
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=int(raw_count or 0),
|
||||||
|
retry_after_seconds=max(0, int(raw_ttl or 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
result = self._client.eval(
|
||||||
|
_REDIS_INCREMENT_SCRIPT,
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
window_seconds,
|
||||||
|
)
|
||||||
|
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||||
|
raise RedisError("Unexpected fixed-window throttle response from Redis")
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=int(result[0]),
|
||||||
|
retry_after_seconds=max(1, int(result[1])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._client.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
class ResilientFixedWindowStore:
|
||||||
|
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
primary: FixedWindowStore | None,
|
||||||
|
fallback: FixedWindowStore,
|
||||||
|
*,
|
||||||
|
retry_seconds: int = 30,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._primary = primary
|
||||||
|
self._fallback = fallback
|
||||||
|
self._retry_seconds = max(1, retry_seconds)
|
||||||
|
self._clock = clock
|
||||||
|
self._primary_unavailable_until = 0.0
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
fallback_result = self._fallback.read(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
return _stricter_bucket(primary.read(key), fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
fallback_result = self._fallback.increment(
|
||||||
|
key,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
primary_result = primary.increment(
|
||||||
|
key,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
return _stricter_bucket(primary_result, fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._fallback.delete(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
primary.delete(key)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
|
||||||
|
def _available_primary(self) -> FixedWindowStore | None:
|
||||||
|
if self._primary is None:
|
||||||
|
return None
|
||||||
|
with self._state_lock:
|
||||||
|
if self._clock() < self._primary_unavailable_until:
|
||||||
|
return None
|
||||||
|
return self._primary
|
||||||
|
|
||||||
|
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||||
|
should_log = False
|
||||||
|
with self._state_lock:
|
||||||
|
now = self._clock()
|
||||||
|
if now >= self._primary_unavailable_until:
|
||||||
|
should_log = True
|
||||||
|
self._primary_unavailable_until = now + self._retry_seconds
|
||||||
|
if should_log:
|
||||||
|
logger.warning(
|
||||||
|
"Distributed throttling is unavailable; using the process-local fallback (%s)",
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stricter_bucket(
|
||||||
|
first: FixedWindowBucket,
|
||||||
|
second: FixedWindowBucket,
|
||||||
|
) -> FixedWindowBucket:
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=max(first.count, second.count),
|
||||||
|
retry_after_seconds=max(
|
||||||
|
first.retry_after_seconds,
|
||||||
|
second.retry_after_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowThrottle:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: FixedWindowStore,
|
||||||
|
*,
|
||||||
|
window_seconds: int,
|
||||||
|
key_prefix: str = "govoplan:throttle:v1",
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._window_seconds = max(1, window_seconds)
|
||||||
|
self._key_prefix = key_prefix.rstrip(":")
|
||||||
|
|
||||||
|
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||||
|
return self._decision(tuple(dimensions), increment=False)
|
||||||
|
|
||||||
|
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||||
|
return self._decision(tuple(dimensions), increment=True)
|
||||||
|
|
||||||
|
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
|
||||||
|
for dimension in tuple(dimensions):
|
||||||
|
self._store.delete(self._key(dimension))
|
||||||
|
|
||||||
|
def _decision(
|
||||||
|
self,
|
||||||
|
dimensions: tuple[ThrottleDimension, ...],
|
||||||
|
*,
|
||||||
|
increment: bool,
|
||||||
|
) -> ThrottleDecision:
|
||||||
|
if not dimensions:
|
||||||
|
raise ValueError("At least one throttle dimension is required")
|
||||||
|
blocked_retry_after = 0
|
||||||
|
for dimension in dimensions:
|
||||||
|
if dimension.limit < 1:
|
||||||
|
raise ValueError("Throttle limits must be positive")
|
||||||
|
key = self._key(dimension)
|
||||||
|
state = (
|
||||||
|
self._store.increment(key, window_seconds=self._window_seconds)
|
||||||
|
if increment
|
||||||
|
else self._store.read(key)
|
||||||
|
)
|
||||||
|
if state.count >= dimension.limit:
|
||||||
|
blocked_retry_after = max(
|
||||||
|
blocked_retry_after,
|
||||||
|
max(1, state.retry_after_seconds),
|
||||||
|
)
|
||||||
|
return ThrottleDecision(
|
||||||
|
allowed=blocked_retry_after == 0,
|
||||||
|
retry_after_seconds=blocked_retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _key(self, dimension: ThrottleDimension) -> str:
|
||||||
|
namespace = dimension.namespace.strip().casefold()
|
||||||
|
if not _NAMESPACE_PATTERN.fullmatch(namespace):
|
||||||
|
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
|
||||||
|
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
|
||||||
|
return f"{self._key_prefix}:{namespace}:{subject_digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_fixed_window_throttle(
|
||||||
|
*,
|
||||||
|
redis_url: str | None,
|
||||||
|
window_seconds: int,
|
||||||
|
redis_retry_seconds: int = 30,
|
||||||
|
max_local_entries: int = 10_000,
|
||||||
|
key_prefix: str = "govoplan:throttle:v1",
|
||||||
|
) -> FixedWindowThrottle:
|
||||||
|
primary = (
|
||||||
|
RedisFixedWindowStore(redis_url)
|
||||||
|
if redis_url is not None and redis_url.strip()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
store = ResilientFixedWindowStore(
|
||||||
|
primary,
|
||||||
|
InMemoryFixedWindowStore(max_entries=max_local_entries),
|
||||||
|
retry_seconds=redis_retry_seconds,
|
||||||
|
)
|
||||||
|
return FixedWindowThrottle(
|
||||||
|
store,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FixedWindowBucket",
|
||||||
|
"FixedWindowStore",
|
||||||
|
"FixedWindowThrottle",
|
||||||
|
"InMemoryFixedWindowStore",
|
||||||
|
"RedisFixedWindowStore",
|
||||||
|
"ResilientFixedWindowStore",
|
||||||
|
"ThrottleDecision",
|
||||||
|
"ThrottleDimension",
|
||||||
|
"build_fixed_window_throttle",
|
||||||
|
]
|
||||||
@@ -101,7 +101,7 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
||||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
|
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -359,6 +359,88 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
|
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
|
||||||
registry.validate()
|
registry.validate()
|
||||||
|
|
||||||
|
def test_default_authenticated_role_templates_are_managed_tenant_roles(self) -> None:
|
||||||
|
for template, message in (
|
||||||
|
(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="invalid-system-default",
|
||||||
|
name="Invalid",
|
||||||
|
description="Invalid system default.",
|
||||||
|
permissions=("system:*",),
|
||||||
|
level="system",
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
|
"must be tenant-level",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="invalid-unmanaged-default",
|
||||||
|
name="Invalid",
|
||||||
|
description="Invalid unmanaged default.",
|
||||||
|
permissions=("tenant:*",),
|
||||||
|
managed=False,
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
|
"must be managed",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.subTest(template=template.slug):
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
role_templates=(template,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RegistryError, message):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_default_authenticated_role_templates_reject_wildcard_permissions(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="unsafe-default",
|
||||||
|
name="Unsafe default",
|
||||||
|
description="Must not grant broad authority automatically.",
|
||||||
|
permissions=("tenant:*",),
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "must use explicit permissions"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_role_template_slugs_are_unique_per_level_across_modules(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
for module_id in ("first", "second"):
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id=module_id,
|
||||||
|
name=module_id.title(),
|
||||||
|
version="test",
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="shared-reader",
|
||||||
|
name="Shared reader",
|
||||||
|
description="A colliding role template.",
|
||||||
|
permissions=(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "Duplicate tenant role template slug"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
|
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
|
||||||
registry = PlatformRegistry()
|
registry = PlatformRegistry()
|
||||||
registry.register(ModuleManifest(
|
registry.register(ModuleManifest(
|
||||||
|
|||||||
130
tests/test_throttling.py
Normal file
130
tests/test_throttling.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
from govoplan_core.core.throttling import (
|
||||||
|
FixedWindowBucket,
|
||||||
|
FixedWindowThrottle,
|
||||||
|
InMemoryFixedWindowStore,
|
||||||
|
ResilientFixedWindowStore,
|
||||||
|
ThrottleDimension,
|
||||||
|
build_fixed_window_throttle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.value = 100.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.values: dict[str, int] = {}
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
return FixedWindowBucket(self.values.get(key, 0), 60)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
del window_seconds
|
||||||
|
self.values[key] = self.values.get(key, 0) + 1
|
||||||
|
return FixedWindowBucket(self.values[key], 60)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self.values.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingStore(_RecordingStore):
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
del key
|
||||||
|
raise RedisError("offline")
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
del key, window_seconds
|
||||||
|
raise RedisError("offline")
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
del key
|
||||||
|
raise RedisError("offline")
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowThrottleTests(unittest.TestCase):
|
||||||
|
def test_blocks_at_limit_and_expires_without_sleeping(self) -> None:
|
||||||
|
clock = _Clock()
|
||||||
|
store = InMemoryFixedWindowStore(clock=clock)
|
||||||
|
throttle = FixedWindowThrottle(store, window_seconds=60)
|
||||||
|
dimensions = (ThrottleDimension("password", "secret-subject", 3),)
|
||||||
|
|
||||||
|
self.assertTrue(throttle.check(dimensions).allowed)
|
||||||
|
self.assertTrue(throttle.record(dimensions).allowed)
|
||||||
|
self.assertTrue(throttle.record(dimensions).allowed)
|
||||||
|
blocked = throttle.record(dimensions)
|
||||||
|
self.assertFalse(blocked.allowed)
|
||||||
|
self.assertEqual(blocked.retry_after_seconds, 60)
|
||||||
|
self.assertFalse(throttle.check(dimensions).allowed)
|
||||||
|
|
||||||
|
clock.value += 61
|
||||||
|
self.assertTrue(throttle.check(dimensions).allowed)
|
||||||
|
|
||||||
|
def test_subject_is_hashed_and_success_can_reset_it(self) -> None:
|
||||||
|
store = _RecordingStore()
|
||||||
|
throttle = FixedWindowThrottle(store, window_seconds=60)
|
||||||
|
dimensions = (ThrottleDimension("poll-participation-password", "tenant:token-secret", 2),)
|
||||||
|
|
||||||
|
throttle.record(dimensions)
|
||||||
|
|
||||||
|
self.assertEqual(len(store.values), 1)
|
||||||
|
stored_key = next(iter(store.values))
|
||||||
|
self.assertNotIn("tenant:token-secret", stored_key)
|
||||||
|
self.assertIn(":poll-participation-password:", stored_key)
|
||||||
|
throttle.reset(dimensions)
|
||||||
|
self.assertEqual(store.values, {})
|
||||||
|
|
||||||
|
def test_resilient_store_uses_bounded_local_fallback(self) -> None:
|
||||||
|
clock = _Clock()
|
||||||
|
fallback = InMemoryFixedWindowStore(max_entries=2, clock=clock)
|
||||||
|
store = ResilientFixedWindowStore(
|
||||||
|
_FailingStore(),
|
||||||
|
fallback,
|
||||||
|
retry_seconds=30,
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = store.increment("first", window_seconds=60)
|
||||||
|
second = store.increment("first", window_seconds=60)
|
||||||
|
store.increment("second", window_seconds=60)
|
||||||
|
store.increment("third", window_seconds=60)
|
||||||
|
|
||||||
|
self.assertEqual(first.count, 1)
|
||||||
|
self.assertEqual(second.count, 2)
|
||||||
|
self.assertEqual(fallback.read("first").count, 0)
|
||||||
|
self.assertEqual(fallback.read("third").count, 1)
|
||||||
|
|
||||||
|
def test_builder_operates_without_redis(self) -> None:
|
||||||
|
throttle = build_fixed_window_throttle(
|
||||||
|
redis_url=None,
|
||||||
|
window_seconds=30,
|
||||||
|
max_local_entries=10,
|
||||||
|
)
|
||||||
|
dimensions = (ThrottleDimension("test", "subject", 1),)
|
||||||
|
|
||||||
|
self.assertFalse(throttle.record(dimensions).allowed)
|
||||||
|
self.assertFalse(throttle.check(dimensions).allowed)
|
||||||
|
|
||||||
|
def test_rejects_invalid_dimensions(self) -> None:
|
||||||
|
throttle = FixedWindowThrottle(_RecordingStore(), window_seconds=60)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "At least one"):
|
||||||
|
throttle.check(())
|
||||||
|
with self.assertRaisesRegex(ValueError, "positive"):
|
||||||
|
throttle.check((ThrottleDimension("test", "subject", 0),))
|
||||||
|
with self.assertRaisesRegex(ValueError, "namespaces"):
|
||||||
|
throttle.check((ThrottleDimension("Invalid namespace", "subject", 1),))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
|
|||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||||
requestNavigation: (action: UnsavedNavigationAction) => void;
|
requestNavigation: (action: UnsavedNavigationAction) => void;
|
||||||
|
/**
|
||||||
|
* Route an explicit Discard button through the same confirmation used for
|
||||||
|
* dirty navigation. The action runs after either saving or discarding.
|
||||||
|
*/
|
||||||
|
requestDiscard: (action: UnsavedNavigationAction) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||||
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
setPendingAction(() => action);
|
setPendingAction(() => action);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
|
||||||
|
requestNavigation(action);
|
||||||
|
}, [requestNavigation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
const active = registrationRef.current;
|
const active = registrationRef.current;
|
||||||
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
registerUnsavedChanges,
|
registerUnsavedChanges,
|
||||||
requestNavigation
|
requestNavigation,
|
||||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
requestDiscard
|
||||||
|
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnsavedChangesContext.Provider value={value}>
|
<UnsavedChangesContext.Provider value={value}>
|
||||||
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
registerUnsavedChanges: () => () => undefined,
|
registerUnsavedChanges: () => () => undefined,
|
||||||
requestNavigation: (action) => action()
|
requestNavigation: (action) => action(),
|
||||||
|
requestDiscard: (action) => action()
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useUnsavedChanges() {
|
export function useUnsavedChanges() {
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ type DataGridProps<T> = {
|
|||||||
export type DataGridRowActionsProps = {
|
export type DataGridRowActionsProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
removeDisabled?: boolean;
|
removeDisabled?: boolean;
|
||||||
|
/** Set to false only when row ordering is not part of this table's action set. */
|
||||||
|
reorderable?: boolean;
|
||||||
onAddBelow: () => void;
|
onAddBelow: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onMoveUp?: () => void;
|
onMoveUp?: () => void;
|
||||||
@@ -858,6 +860,7 @@ export function DataGridPaginationBar({
|
|||||||
export function DataGridRowActions({
|
export function DataGridRowActions({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
removeDisabled = false,
|
removeDisabled = false,
|
||||||
|
reorderable = true,
|
||||||
onAddBelow,
|
onAddBelow,
|
||||||
onRemove,
|
onRemove,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
@@ -879,20 +882,18 @@ export function DataGridRowActions({
|
|||||||
disabled,
|
disabled,
|
||||||
onClick: onAddBelow
|
onClick: onAddBelow
|
||||||
},
|
},
|
||||||
{
|
reorderable && {
|
||||||
id: "move-up",
|
id: "move-up",
|
||||||
label: moveUpLabel,
|
label: moveUpLabel,
|
||||||
icon: <ArrowUp size={16} aria-hidden="true" />,
|
icon: <ArrowUp size={16} aria-hidden="true" />,
|
||||||
applicable: Boolean(onMoveUp),
|
disabled: disabled || !onMoveUp,
|
||||||
disabled,
|
|
||||||
onClick: () => onMoveUp?.()
|
onClick: () => onMoveUp?.()
|
||||||
},
|
},
|
||||||
{
|
reorderable && {
|
||||||
id: "move-down",
|
id: "move-down",
|
||||||
label: moveDownLabel,
|
label: moveDownLabel,
|
||||||
icon: <ArrowDown size={16} aria-hidden="true" />,
|
icon: <ArrowDown size={16} aria-hidden="true" />,
|
||||||
applicable: Boolean(onMoveDown),
|
disabled: disabled || !onMoveDown,
|
||||||
disabled,
|
|
||||||
onClick: () => onMoveDown?.()
|
onClick: () => onMoveDown?.()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -910,16 +911,18 @@ export function DataGridRowActions({
|
|||||||
|
|
||||||
export function DataGridEmptyAction({
|
export function DataGridEmptyAction({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
reorderable = true,
|
||||||
onAdd,
|
onAdd,
|
||||||
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
|
}: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
|
||||||
return (
|
return (
|
||||||
<TableActionGroup
|
<TableActionGroup
|
||||||
className="data-grid-row-actions data-grid-empty-row-actions"
|
className="data-grid-row-actions data-grid-empty-row-actions"
|
||||||
|
minimumSlots={reorderable ? 4 : 2}
|
||||||
actions={[{
|
actions={[{
|
||||||
id: "add",
|
id: "add",
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ export type TableActionDefinition = {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
/** Set to false when the action does not apply to this row. */
|
/**
|
||||||
|
* @deprecated Use `disabled` for a row-level unavailable state and omit the
|
||||||
|
* action from the array only when it is not part of this table's action set.
|
||||||
|
* A false value is retained as a disabled, visible action for compatibility.
|
||||||
|
*/
|
||||||
applicable?: boolean;
|
applicable?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
disabledReason?: ReactNode;
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,12 +22,14 @@ export type TableActionGroupProps = {
|
|||||||
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
||||||
label?: string;
|
label?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Reserve trailing action slots so controls keep the same column position. */
|
||||||
|
minimumSlots?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isApplicableAction(
|
function isDefinedAction(
|
||||||
action: TableActionDefinition | false | null | undefined
|
action: TableActionDefinition | false | null | undefined
|
||||||
): action is TableActionDefinition {
|
): action is TableActionDefinition {
|
||||||
return Boolean(action && action.applicable !== false);
|
return Boolean(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runTableAction(
|
export function runTableAction(
|
||||||
@@ -36,19 +43,24 @@ export function runTableAction(
|
|||||||
/**
|
/**
|
||||||
* The standard row-level action surface for tables and DataGrids.
|
* The standard row-level action surface for tables and DataGrids.
|
||||||
*
|
*
|
||||||
* Callers describe only actions that make sense for the row. An action may be
|
* Callers define the action set for the whole table. An action that cannot be
|
||||||
* disabled when it applies but is temporarily unavailable; actions marked as
|
* used for one row stays visible and disabled; an action that is structurally
|
||||||
* not applicable are omitted entirely. Labels remain available to assistive
|
* irrelevant to the table is omitted from the array. Labels remain available
|
||||||
* technology and as native tooltips while the visible controls stay icon-only.
|
* to assistive technology and as native tooltips while the visible controls
|
||||||
|
* stay icon-only. `minimumSlots` reserves trailing positions for empty-state
|
||||||
|
* rows, keeping their first action aligned with ordinary rows.
|
||||||
*/
|
*/
|
||||||
export default function TableActionGroup({
|
export default function TableActionGroup({
|
||||||
actions,
|
actions,
|
||||||
label = "i18n:govoplan-core.actions.c3cd636a",
|
label = "i18n:govoplan-core.actions.c3cd636a",
|
||||||
className = ""
|
className = "",
|
||||||
|
minimumSlots = 0
|
||||||
}: TableActionGroupProps) {
|
}: TableActionGroupProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const applicableActions = actions.filter(isApplicableAction);
|
const definedActions = actions.filter(isDefinedAction);
|
||||||
if (applicableActions.length === 0) return null;
|
if (definedActions.length === 0) return null;
|
||||||
|
const normalizedMinimumSlots = Number.isFinite(minimumSlots) ? Math.max(0, Math.floor(minimumSlots)) : 0;
|
||||||
|
const placeholderCount = Math.max(0, normalizedMinimumSlots - definedActions.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -56,7 +68,7 @@ export default function TableActionGroup({
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label={translateText(label)}
|
aria-label={translateText(label)}
|
||||||
>
|
>
|
||||||
{applicableActions.map((action) => {
|
{definedActions.map((action) => {
|
||||||
const translatedLabel = translateText(action.label);
|
const translatedLabel = translateText(action.label);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -66,7 +78,8 @@ export default function TableActionGroup({
|
|||||||
className="table-action-button"
|
className="table-action-button"
|
||||||
aria-label={translatedLabel}
|
aria-label={translatedLabel}
|
||||||
title={translatedLabel}
|
title={translatedLabel}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled || action.applicable === false}
|
||||||
|
disabledReason={action.disabledReason}
|
||||||
onClick={(event) => runTableAction(event, action.onClick)}
|
onClick={(event) => runTableAction(event, action.onClick)}
|
||||||
>
|
>
|
||||||
<span className="table-action-icon" aria-hidden="true">
|
<span className="table-action-icon" aria-hidden="true">
|
||||||
@@ -75,6 +88,13 @@ export default function TableActionGroup({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{Array.from({ length: placeholderCount }, (_unused, index) => (
|
||||||
|
<span
|
||||||
|
key={`reserved-action-slot-${index}`}
|
||||||
|
className="table-action-placeholder"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,15 @@ export default function SettingsPage({
|
|||||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
|
||||||
|
"mail_servers:read",
|
||||||
|
"mail_servers:write",
|
||||||
|
"mail_servers:manage_credentials",
|
||||||
|
"mail:profile:write_own",
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
"admin:policies:read",
|
||||||
|
"admin:policies:write"
|
||||||
|
]);
|
||||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||||
const contributedSections = useMemo(
|
const contributedSections = useMemo(
|
||||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||||
@@ -319,8 +327,8 @@ export default function SettingsPage({
|
|||||||
scopeId={auth.user.id}
|
scopeId={auth.user.id}
|
||||||
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
||||||
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
||||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
|
||||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
|
||||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -686,6 +686,15 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-action-placeholder {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 36px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.data-grid-empty-message {
|
.data-grid-empty-message {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
|
|||||||
@@ -32,18 +32,23 @@ const emptyAction = renderToStaticMarkup(
|
|||||||
);
|
);
|
||||||
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
||||||
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
||||||
|
assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "empty actions reserve the remaining ordinary row positions");
|
||||||
|
|
||||||
const contextActions = renderToStaticMarkup(
|
const contextActions = renderToStaticMarkup(
|
||||||
<TableActionGroup
|
<TableActionGroup
|
||||||
actions={[
|
actions={[
|
||||||
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
||||||
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
|
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
|
||||||
{ id: "remove", label: "Remove", icon: <span>R</span>, disabled: true, onClick: noop }
|
{ id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
|
||||||
]}
|
]}
|
||||||
|
minimumSlots={4}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assertEqual(buttonCount(contextActions), 2, "table action groups omit actions that do not apply");
|
assertEqual(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
|
||||||
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form");
|
assertEqual(nonSubmittingButtonCount(contextActions), 3, "table action groups do not submit an enclosing form");
|
||||||
|
assertEqual((contextActions.match(/disabled=""/g) ?? []).length, 2, "unavailable actions are disabled");
|
||||||
|
assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1, "minimum action slots reserve trailing positions");
|
||||||
|
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
|
||||||
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
|
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
|
||||||
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
|
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
|
||||||
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
|
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
|
||||||
@@ -58,10 +63,16 @@ assertEqual(propagationStopped, true, "table actions do not trigger clickable ro
|
|||||||
assertEqual(actionCalled, true, "table actions still invoke their handler");
|
assertEqual(actionCalled, true, "table actions still invoke their handler");
|
||||||
|
|
||||||
const noReorderActions = renderToStaticMarkup(
|
const noReorderActions = renderToStaticMarkup(
|
||||||
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
|
<DataGridRowActions reorderable={false} onAddBelow={noop} onRemove={noop} />
|
||||||
);
|
);
|
||||||
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
|
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
|
||||||
|
|
||||||
|
const unavailableReorderActions = renderToStaticMarkup(
|
||||||
|
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
|
||||||
|
);
|
||||||
|
assertEqual(buttonCount(unavailableReorderActions), 4, "ordered rows retain unavailable boundary controls");
|
||||||
|
assertEqual((unavailableReorderActions.match(/disabled=""/g) ?? []).length, 2, "unavailable boundary controls are disabled");
|
||||||
|
|
||||||
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
|
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
|
||||||
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
||||||
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
||||||
|
|||||||
Reference in New Issue
Block a user