Compare commits
13 Commits
ab07075a67
...
v0.1.11
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d64d247e | |||
| 198803d5b9 | |||
| 79781662e8 | |||
| 4075ac4d73 | |||
| 21f77ffc50 | |||
| d0ef0531c0 | |||
| c2917459a4 | |||
| fb1b573855 | |||
| 8c74e360d2 | |||
| e91935c03a | |||
| 01d082e552 | |||
| 90d8f65835 | |||
| 1c8e18e109 |
26
README.md
26
README.md
@@ -18,7 +18,13 @@ module directly. Access-side admin service helpers remain
|
|||||||
here for users, groups, roles, system accounts, sessions, API keys, tenant
|
here for users, groups, roles, system accounts, sessions, API keys, tenant
|
||||||
access enforcement, admin/audit lookup capabilities, tenant owner
|
access enforcement, admin/audit lookup capabilities, tenant owner
|
||||||
provisioning, and governance-template materialization into access-owned groups
|
provisioning, and governance-template materialization into access-owned groups
|
||||||
and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The
|
and roles. The module also enforces narrowly declared managed
|
||||||
|
`RoleTemplate.default_authenticated` baselines. Their explicit permissions are
|
||||||
|
derived from the active manifest set for every authenticated tenant member,
|
||||||
|
without writing assignments during an authorization read. The optional role
|
||||||
|
row is a non-assignable administration projection, not the source of the
|
||||||
|
automatic grant. Domain permissions and resource policy remain separate checks.
|
||||||
|
Governance-template metadata CRUD lives in `govoplan-admin`. The
|
||||||
transitional administration WebUI route shell and
|
transitional administration WebUI route shell and
|
||||||
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
|
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
|
||||||
system administration panels are contributed by `@govoplan/admin-webui` through
|
system administration panels are contributed by `@govoplan/admin-webui` through
|
||||||
@@ -113,3 +119,21 @@ From the core checkout:
|
|||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
./.venv/bin/python -m pip install -e ../govoplan-access
|
./.venv/bin/python -m pip install -e ../govoplan-access
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Login Throttling
|
||||||
|
|
||||||
|
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
|
||||||
|
`REDIS_URL` when Redis is reachable, allowing all API workers to share the same
|
||||||
|
limits. Local development and Redis outages fall back automatically to a
|
||||||
|
bounded, process-local counter; authentication remains available, but limits
|
||||||
|
then apply per API process.
|
||||||
|
|
||||||
|
The deployment settings are `AUTH_LOGIN_THROTTLE_ENABLED`,
|
||||||
|
`AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT`, `AUTH_LOGIN_THROTTLE_CLIENT_LIMIT`,
|
||||||
|
`AUTH_LOGIN_THROTTLE_WINDOW_SECONDS`, and
|
||||||
|
`AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS`. Client-supplied forwarding headers
|
||||||
|
are not trusted for throttling. A reverse proxy should pass the real peer
|
||||||
|
address only through the platform's separately configured trusted-proxy
|
||||||
|
boundary.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-access"
|
name = "govoplan-access"
|
||||||
version = "0.1.8"
|
version = "0.1.11"
|
||||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.11",
|
||||||
|
"redis>=5,<6",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN access platform module."""
|
"""GovOPlaN access platform module."""
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.11"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from govoplan_access.backend.permissions.catalog import (
|
|||||||
validate_tenant_permissions,
|
validate_tenant_permissions,
|
||||||
role_templates_for_level,
|
role_templates_for_level,
|
||||||
)
|
)
|
||||||
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper
|
from govoplan_core.tenancy.service import tenant_counts # noqa: F401 - re-exported compatibility helper
|
||||||
|
|
||||||
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
|
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict
|
|||||||
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
|
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
|
||||||
role = query.one_or_none()
|
role = query.one_or_none()
|
||||||
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||||
is_assignable = True
|
is_assignable = not template.default_authenticated
|
||||||
if role is None:
|
if role is None:
|
||||||
role = Role(
|
role = Role(
|
||||||
tenant_id=tenant.id if tenant is not None else None,
|
tenant_id=tenant.id if tenant is not None else None,
|
||||||
@@ -200,7 +200,24 @@ def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -
|
|||||||
|
|
||||||
|
|
||||||
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
|
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
|
||||||
ids = sorted(set(role_ids))
|
default_slugs = {
|
||||||
|
template.slug
|
||||||
|
for template in role_templates_for_level("tenant")
|
||||||
|
if template.default_authenticated
|
||||||
|
}
|
||||||
|
default_roles = (
|
||||||
|
session.query(Role)
|
||||||
|
.filter(
|
||||||
|
Role.tenant_id == user.tenant_id,
|
||||||
|
Role.slug.in_(default_slugs),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
if default_slugs
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
default_role_ids = {role.id for role in default_roles}
|
||||||
|
requested_ids = set(role_ids) - default_role_ids
|
||||||
|
ids = sorted(requested_ids)
|
||||||
roles = (
|
roles = (
|
||||||
session.query(Role)
|
session.query(Role)
|
||||||
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
|
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
|
||||||
|
|||||||
@@ -714,7 +714,7 @@ class ConfigurationSafetyFieldItem(BaseModel):
|
|||||||
storage: str
|
storage: str
|
||||||
ui_managed: bool
|
ui_managed: bool
|
||||||
risk: Literal["low", "medium", "high", "destructive"]
|
risk: Literal["low", "medium", "high", "destructive"]
|
||||||
secret_handling: Literal["none", "reference_only", "env_only"] = "none"
|
secret_handling: Literal["none", "reference_only", "env_only"] = "none" # noqa: S105 - policy vocabulary.
|
||||||
required_scopes: list[str] = Field(default_factory=list)
|
required_scopes: list[str] = Field(default_factory=list)
|
||||||
dry_run_required: bool = False
|
dry_run_required: bool = False
|
||||||
validation_required: bool = True
|
validation_required: bool = True
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -50,7 +51,12 @@ 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.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.auth.tenant_context import AccessTenantContextSwitcher
|
||||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||||
from govoplan_access.backend.security.passwords import verify_password
|
from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
|
||||||
|
from govoplan_access.backend.security.login_throttle import (
|
||||||
|
LoginThrottle,
|
||||||
|
LoginThrottleDecision,
|
||||||
|
build_login_throttle,
|
||||||
|
)
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
authenticate_session_token,
|
authenticate_session_token,
|
||||||
collect_user_authorization_context,
|
collect_user_authorization_context,
|
||||||
@@ -253,7 +259,9 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
|
|||||||
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
||||||
.one_or_none()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
if account is None or not verify_password(payload.password, account.password_hash):
|
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:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
@@ -269,10 +277,73 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
|
|||||||
query = query.filter(Tenant.slug == payload.tenant_slug)
|
query = query.filter(Tenant.slug == payload.tenant_slug)
|
||||||
row = query.order_by(Tenant.name.asc()).first()
|
row = query.order_by(Tenant.name.asc()).first()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership")
|
# Keep every authentication failure generic so callers cannot infer
|
||||||
|
# whether an account exists but lacks an active tenant membership.
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
||||||
return account, row[0], row[1]
|
return account, row[0], row[1]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _configured_login_throttle() -> LoginThrottle | None:
|
||||||
|
if not settings.auth_login_throttle_enabled:
|
||||||
|
return None
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_login_throttled(decision: LoginThrottleDecision) -> None:
|
||||||
|
retry_after = max(1, decision.retry_after_seconds)
|
||||||
|
# The detail deliberately matches every credential failure. Status and
|
||||||
|
# Retry-After communicate endpoint throttling without disclosing whether
|
||||||
|
# the supplied identity exists or has an active membership.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Invalid login",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_throttled_login_user(
|
||||||
|
session: Session,
|
||||||
|
payload: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> tuple[Account, User, Tenant]:
|
||||||
|
throttle = _configured_login_throttle()
|
||||||
|
if throttle is None:
|
||||||
|
return _resolve_login_user(session, payload)
|
||||||
|
|
||||||
|
normalized_email = normalize_email(payload.email)
|
||||||
|
client_address = request.client.host if request.client else None
|
||||||
|
context = {
|
||||||
|
"normalized_email": normalized_email,
|
||||||
|
"tenant_slug": payload.tenant_slug,
|
||||||
|
"client_address": client_address,
|
||||||
|
}
|
||||||
|
decision = throttle.check(**context)
|
||||||
|
if not decision.allowed:
|
||||||
|
_raise_login_throttled(decision)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = _resolve_login_user(session, payload)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
|
||||||
|
decision = throttle.record_failure(**context)
|
||||||
|
if not decision.allowed:
|
||||||
|
_raise_login_throttled(decision)
|
||||||
|
raise
|
||||||
|
|
||||||
|
throttle.record_success(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
tenant_slug=payload.tenant_slug,
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def _extract_auth_token(request: Request) -> tuple[str | None, str]:
|
def _extract_auth_token(request: Request) -> tuple[str | None, str]:
|
||||||
x_api_key = request.headers.get("x-api-key")
|
x_api_key = request.headers.get("x-api-key")
|
||||||
if x_api_key:
|
if x_api_key:
|
||||||
@@ -616,7 +687,7 @@ def _me_response(
|
|||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
|
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
|
||||||
account, user, tenant = _resolve_login_user(session, payload)
|
account, user, tenant = _resolve_throttled_login_user(session, payload, request)
|
||||||
identity_directory = _identity_directory_from_request(request)
|
identity_directory = _identity_directory_from_request(request)
|
||||||
authorization_context = collect_user_authorization_context(
|
authorization_context = collect_user_authorization_context(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ from govoplan_access.backend.semantic import collect_external_function_roles, co
|
|||||||
from govoplan_access.backend.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes
|
from govoplan_access.backend.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_access.backend.permissions.catalog import (
|
from govoplan_access.backend.permissions.catalog import (
|
||||||
normalize_email,
|
|
||||||
permission_catalog as access_permission_catalog,
|
permission_catalog as access_permission_catalog,
|
||||||
scopes_grant,
|
scopes_grant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
|
||||||
from govoplan_access.backend.db.base import AccessBase
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -33,12 +34,13 @@ from govoplan_core.core.modules import (
|
|||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
|
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
|
||||||
@@ -597,11 +599,20 @@ def _route_factory(context: ModuleContext):
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _people_search(context: ModuleContext) -> object:
|
||||||
|
from govoplan_access.backend.people_search import people_search_capability
|
||||||
|
|
||||||
|
return people_search_capability(context)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="access",
|
id="access",
|
||||||
name="Access",
|
name="Access",
|
||||||
version="0.1.8",
|
version="0.1.11",
|
||||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||||
|
),
|
||||||
permissions=ACCESS_PERMISSIONS,
|
permissions=ACCESS_PERMISSIONS,
|
||||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
@@ -653,6 +664,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
|
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
|
||||||
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
||||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
|
||||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||||
},
|
},
|
||||||
documentation=ACCESS_DOCUMENTATION,
|
documentation=ACCESS_DOCUMENTATION,
|
||||||
|
|||||||
102
src/govoplan_access/backend/people_search.py
Normal file
102
src/govoplan_access/backend/people_search.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, User
|
||||||
|
from govoplan_core.core.people import (
|
||||||
|
PeopleSearchError,
|
||||||
|
PeopleSearchGroup,
|
||||||
|
PersonSearchCandidate,
|
||||||
|
person_selection_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_tenant_id(principal: object) -> str:
|
||||||
|
try:
|
||||||
|
tenant_id = getattr(principal, "tenant_id")
|
||||||
|
except (AttributeError, RuntimeError) as exc:
|
||||||
|
raise PeopleSearchError("People search requires an active tenant context.") from exc
|
||||||
|
normalized = str(tenant_id or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise PeopleSearchError("People search requires an active tenant context.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _like_pattern(query: str) -> str:
|
||||||
|
escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
return f"%{escaped}%"
|
||||||
|
|
||||||
|
|
||||||
|
class AccessPeopleSearchProvider:
|
||||||
|
"""Search active accounts through their active tenant membership.
|
||||||
|
|
||||||
|
The active principal's tenant is the only accepted visibility boundary;
|
||||||
|
global accounts and memberships of other tenants are never candidates.
|
||||||
|
Feature routers authorize the surrounding task before invoking this
|
||||||
|
capability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_people(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> tuple[PeopleSearchGroup, ...]:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise PeopleSearchError("People search requires a database session.")
|
||||||
|
tenant_id = _principal_tenant_id(principal)
|
||||||
|
normalized_limit = max(1, min(int(limit), 100))
|
||||||
|
account_query = (
|
||||||
|
session.query(User, Account)
|
||||||
|
.join(Account, Account.id == User.account_id)
|
||||||
|
.filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
Account.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
normalized_query = str(query or "").strip()
|
||||||
|
if normalized_query:
|
||||||
|
pattern = _like_pattern(normalized_query)
|
||||||
|
account_query = account_query.filter(
|
||||||
|
or_(
|
||||||
|
func.lower(User.display_name).like(pattern, escape="\\"),
|
||||||
|
func.lower(User.email).like(pattern, escape="\\"),
|
||||||
|
func.lower(Account.display_name).like(pattern, escape="\\"),
|
||||||
|
func.lower(Account.email).like(pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
account_query
|
||||||
|
.order_by(
|
||||||
|
func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(),
|
||||||
|
Account.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(normalized_limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
candidates = tuple(
|
||||||
|
PersonSearchCandidate(
|
||||||
|
selection_key=person_selection_key("account", account.id),
|
||||||
|
kind="account",
|
||||||
|
reference_id=account.id,
|
||||||
|
display_name=user.display_name or account.display_name or user.email or account.email,
|
||||||
|
email=user.email or account.email,
|
||||||
|
source_module="access",
|
||||||
|
source_label="Accounts",
|
||||||
|
source_ref=f"access:account:{account.id}",
|
||||||
|
)
|
||||||
|
for user, account in rows
|
||||||
|
)
|
||||||
|
return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),)
|
||||||
|
|
||||||
|
|
||||||
|
def people_search_capability(_context: object) -> AccessPeopleSearchProvider:
|
||||||
|
return AccessPeopleSearchProvider()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AccessPeopleSearchProvider", "people_search_capability"]
|
||||||
@@ -100,7 +100,12 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
|
|||||||
for alias in compatible_required_scopes(scope):
|
for alias in compatible_required_scopes(scope):
|
||||||
if alias != scope:
|
if alias != scope:
|
||||||
expanded.add(alias)
|
expanded.add(alias)
|
||||||
if include_unknown and not matched:
|
# Preserve explicitly granted scopes in the presentation set. A
|
||||||
|
# canonical module scope can still match its legacy compatibility
|
||||||
|
# alias when the providing module is not loaded; treating that match
|
||||||
|
# as proof that the original scope is known would otherwise replace
|
||||||
|
# the canonical grant with only the deprecated alias.
|
||||||
|
if include_unknown or scope in catalog:
|
||||||
expanded.add(scope)
|
expanded.add(scope)
|
||||||
return sorted(expanded)
|
return sorted(expanded)
|
||||||
|
|
||||||
|
|||||||
358
src/govoplan_access/backend/security/login_throttle.py
Normal file
358
src/govoplan_access/backend/security/login_throttle.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_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 AttemptBucket:
|
||||||
|
count: int = 0
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LoginThrottleDecision:
|
||||||
|
allowed: bool
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class LoginAttemptStore(Protocol):
|
||||||
|
def read(self, key: str) -> AttemptBucket: ...
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket: ...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryLoginAttemptStore:
|
||||||
|
"""Bounded process-local fallback for development and Redis outages."""
|
||||||
|
|
||||||
|
def __init__(self, *, max_entries: int = 10_000) -> None:
|
||||||
|
self._entries: dict[str, tuple[int, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._max_entries = max(2, max_entries)
|
||||||
|
|
||||||
|
def read(self, key: str) -> AttemptBucket:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
return AttemptBucket()
|
||||||
|
count, expires_at = entry
|
||||||
|
return AttemptBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||||
|
now = time.monotonic()
|
||||||
|
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 AttemptBucket(
|
||||||
|
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
|
||||||
|
expired = [key for key, (_, expires_at) in self._entries.items() if expires_at <= now]
|
||||||
|
for key in expired:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
while len(self._entries) >= self._max_entries:
|
||||||
|
self._entries.pop(next(iter(self._entries)))
|
||||||
|
|
||||||
|
|
||||||
|
class RedisLoginAttemptStore:
|
||||||
|
"""Redis-backed fixed-window counters shared by all 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) -> AttemptBucket:
|
||||||
|
pipeline = self._client.pipeline(transaction=False)
|
||||||
|
pipeline.get(key)
|
||||||
|
pipeline.ttl(key)
|
||||||
|
raw_count, raw_ttl = pipeline.execute()
|
||||||
|
count = int(raw_count or 0)
|
||||||
|
ttl = int(raw_ttl or 0)
|
||||||
|
return AttemptBucket(count=count, retry_after_seconds=max(0, ttl))
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||||
|
result = self._client.eval(_REDIS_INCREMENT_SCRIPT, 1, key, window_seconds)
|
||||||
|
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||||
|
raise RedisError("Unexpected login throttle response from Redis")
|
||||||
|
return AttemptBucket(
|
||||||
|
count=int(result[0]),
|
||||||
|
retry_after_seconds=max(1, int(result[1])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._client.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
class ResilientLoginAttemptStore:
|
||||||
|
"""Prefer the distributed store and fail safely to a local bounded store."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
primary: LoginAttemptStore | None,
|
||||||
|
fallback: LoginAttemptStore,
|
||||||
|
*,
|
||||||
|
retry_seconds: int = 30,
|
||||||
|
) -> None:
|
||||||
|
self._primary = primary
|
||||||
|
self._fallback = fallback
|
||||||
|
self._retry_seconds = max(1, retry_seconds)
|
||||||
|
self._primary_unavailable_until = 0.0
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
|
def read(self, key: str) -> AttemptBucket:
|
||||||
|
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) -> AttemptBucket:
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is not None:
|
||||||
|
fallback_result = self._fallback.increment(key, window_seconds=window_seconds)
|
||||||
|
try:
|
||||||
|
# Mirror the active process's failures so a later Redis outage
|
||||||
|
# or recovery cannot restart its protection window from zero.
|
||||||
|
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
|
||||||
|
return self._fallback.increment(key, window_seconds=window_seconds)
|
||||||
|
|
||||||
|
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) -> LoginAttemptStore | None:
|
||||||
|
if self._primary is None:
|
||||||
|
return None
|
||||||
|
with self._state_lock:
|
||||||
|
if time.monotonic() < 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 = time.monotonic()
|
||||||
|
if now >= self._primary_unavailable_until:
|
||||||
|
should_log = True
|
||||||
|
self._primary_unavailable_until = now + self._retry_seconds
|
||||||
|
if should_log:
|
||||||
|
logger.warning(
|
||||||
|
"Redis login throttling is unavailable; using the process-local fallback (%s)",
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stricter_bucket(first: AttemptBucket, second: AttemptBucket) -> AttemptBucket:
|
||||||
|
return AttemptBucket(
|
||||||
|
count=max(first.count, second.count),
|
||||||
|
retry_after_seconds=max(first.retry_after_seconds, second.retry_after_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottle:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: LoginAttemptStore,
|
||||||
|
*,
|
||||||
|
identity_limit: int,
|
||||||
|
client_limit: int,
|
||||||
|
window_seconds: int,
|
||||||
|
key_prefix: str = "govoplan:access:login:v1",
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._identity_limit = max(1, identity_limit)
|
||||||
|
self._client_limit = max(1, client_limit)
|
||||||
|
self._window_seconds = max(1, window_seconds)
|
||||||
|
self._key_prefix = key_prefix.rstrip(":")
|
||||||
|
|
||||||
|
def check(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
tenant_slug: str | None,
|
||||||
|
client_address: str | None,
|
||||||
|
) -> LoginThrottleDecision:
|
||||||
|
return self._decision(
|
||||||
|
self._buckets(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
client_address=client_address,
|
||||||
|
),
|
||||||
|
increment=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_failure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
tenant_slug: str | None,
|
||||||
|
client_address: str | None,
|
||||||
|
) -> LoginThrottleDecision:
|
||||||
|
return self._decision(
|
||||||
|
self._buckets(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
client_address=client_address,
|
||||||
|
),
|
||||||
|
increment=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_success(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
tenant_slug: str | None,
|
||||||
|
) -> None:
|
||||||
|
del tenant_slug
|
||||||
|
identity_key, _ = self._keys(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
client_address=None,
|
||||||
|
)
|
||||||
|
self._store.delete(identity_key)
|
||||||
|
|
||||||
|
def _decision(
|
||||||
|
self,
|
||||||
|
buckets: tuple[tuple[str, int], ...],
|
||||||
|
*,
|
||||||
|
increment: bool,
|
||||||
|
) -> LoginThrottleDecision:
|
||||||
|
blocked_retry_after = 0
|
||||||
|
for key, limit in buckets:
|
||||||
|
state = (
|
||||||
|
self._store.increment(key, window_seconds=self._window_seconds)
|
||||||
|
if increment
|
||||||
|
else self._store.read(key)
|
||||||
|
)
|
||||||
|
if state.count >= limit:
|
||||||
|
blocked_retry_after = max(
|
||||||
|
blocked_retry_after,
|
||||||
|
max(1, state.retry_after_seconds),
|
||||||
|
)
|
||||||
|
return LoginThrottleDecision(
|
||||||
|
allowed=blocked_retry_after == 0,
|
||||||
|
retry_after_seconds=blocked_retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _buckets(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
tenant_slug: str | None,
|
||||||
|
client_address: str | None,
|
||||||
|
) -> tuple[tuple[str, int], ...]:
|
||||||
|
# Accounts and their passwords are global login identities. Do not put
|
||||||
|
# the caller-supplied tenant slug into the identity key: rotating fake
|
||||||
|
# slugs must not bypass the account-level limit.
|
||||||
|
del tenant_slug
|
||||||
|
identity_key, client_key = self._keys(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
client_address=client_address,
|
||||||
|
)
|
||||||
|
buckets = [(identity_key, self._identity_limit)]
|
||||||
|
if client_key is not None:
|
||||||
|
buckets.append((client_key, self._client_limit))
|
||||||
|
return tuple(buckets)
|
||||||
|
|
||||||
|
def _keys(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
client_address: str | None,
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
identity = normalized_email.strip().casefold()
|
||||||
|
identity_digest = hashlib.sha256(identity.encode()).hexdigest()
|
||||||
|
identity_key = f"{self._key_prefix}:identity:{identity_digest}"
|
||||||
|
if not client_address:
|
||||||
|
return identity_key, None
|
||||||
|
client_digest = hashlib.sha256(client_address.strip().casefold().encode()).hexdigest()
|
||||||
|
return identity_key, f"{self._key_prefix}:client:{client_digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_login_throttle(
|
||||||
|
*,
|
||||||
|
redis_url: str | None,
|
||||||
|
identity_limit: int,
|
||||||
|
client_limit: int,
|
||||||
|
window_seconds: int,
|
||||||
|
redis_retry_seconds: int,
|
||||||
|
) -> LoginThrottle:
|
||||||
|
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
|
||||||
|
resilient_store = ResilientLoginAttemptStore(
|
||||||
|
redis_store,
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
retry_seconds=redis_retry_seconds,
|
||||||
|
)
|
||||||
|
return LoginThrottle(
|
||||||
|
resilient_store,
|
||||||
|
identity_limit=identity_limit,
|
||||||
|
client_limit=client_limit,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
@@ -9,6 +9,14 @@ _ALGORITHM = "pbkdf2_sha256"
|
|||||||
_DEFAULT_ITERATIONS = 260_000
|
_DEFAULT_ITERATIONS = 260_000
|
||||||
_SALT_BYTES = 16
|
_SALT_BYTES = 16
|
||||||
|
|
||||||
|
# A valid, fixed-cost hash used when a login identity has no local password hash.
|
||||||
|
# Its plaintext value is intentionally irrelevant; the hash only keeps failed
|
||||||
|
# login attempts on the same verification path as existing local accounts.
|
||||||
|
DUMMY_PASSWORD_HASH = (
|
||||||
|
"pbkdf2_sha256$260000$Z292b3BsYW4tZHVtbXktdjE=" # noqa: S105 # nosec B105 - non-account timing equalizer.
|
||||||
|
"$uWgE7ht8wO6cotOqNKK2yNomPt57gstVss5ben5gTbw="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
|
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
|
||||||
salt = os.urandom(_SALT_BYTES)
|
salt = os.urandom(_SALT_BYTES)
|
||||||
@@ -37,4 +45,3 @@ def verify_password(password: str, encoded: str | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||||
return hmac.compare_digest(actual, expected)
|
return hmac.compare_digest(actual, expected)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from govoplan_access.backend.db.models import (
|
|||||||
UserRoleAssignment,
|
UserRoleAssignment,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
|
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
|
||||||
from govoplan_access.backend.permissions.catalog import expand_scopes
|
from govoplan_access.backend.permissions.catalog import expand_scopes, role_templates_for_level
|
||||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
|
||||||
SESSION_RANDOM_BYTES = 32
|
SESSION_RANDOM_BYTES = 32
|
||||||
@@ -183,6 +183,8 @@ def collect_user_roles(session: Session, user: User) -> list[Role]:
|
|||||||
roles_by_id[role.id] = role
|
roles_by_id[role.id] = role
|
||||||
for role in collect_function_roles(session, user):
|
for role in collect_function_roles(session, user):
|
||||||
roles_by_id[role.id] = role
|
roles_by_id[role.id] = role
|
||||||
|
for role in _materialized_default_authenticated_roles(session, user):
|
||||||
|
roles_by_id[role.id] = role
|
||||||
return list(roles_by_id.values())
|
return list(roles_by_id.values())
|
||||||
|
|
||||||
|
|
||||||
@@ -242,14 +244,24 @@ def collect_user_authorization_context(
|
|||||||
roles_by_id[role.id] = role
|
roles_by_id[role.id] = role
|
||||||
for role in extra_roles:
|
for role in extra_roles:
|
||||||
roles_by_id[role.id] = role
|
roles_by_id[role.id] = role
|
||||||
|
for role in _materialized_default_authenticated_roles(session, user):
|
||||||
|
roles_by_id[role.id] = role
|
||||||
|
|
||||||
tenant_roles = list(roles_by_id.values())
|
tenant_roles = list(roles_by_id.values())
|
||||||
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
|
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
|
||||||
|
default_slugs = _default_authenticated_slugs()
|
||||||
scopes = {
|
scopes = {
|
||||||
scope
|
scope
|
||||||
for role in [*tenant_roles, *system_roles]
|
for role in tenant_roles
|
||||||
|
if role.slug not in default_slugs
|
||||||
for scope in (role.permissions or [])
|
for scope in (role.permissions or [])
|
||||||
}
|
}
|
||||||
|
scopes.update(
|
||||||
|
scope
|
||||||
|
for role in system_roles
|
||||||
|
for scope in (role.permissions or [])
|
||||||
|
)
|
||||||
|
scopes.update(_default_authenticated_scopes())
|
||||||
return UserAuthorizationContext(
|
return UserAuthorizationContext(
|
||||||
tenant_roles=tenant_roles,
|
tenant_roles=tenant_roles,
|
||||||
system_roles=system_roles,
|
system_roles=system_roles,
|
||||||
@@ -261,15 +273,54 @@ def collect_user_authorization_context(
|
|||||||
|
|
||||||
|
|
||||||
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
|
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
|
||||||
scopes: set[str] = set()
|
scopes = _default_authenticated_scopes()
|
||||||
|
default_slugs = _default_authenticated_slugs()
|
||||||
for role in collect_user_roles(session, user):
|
for role in collect_user_roles(session, user):
|
||||||
scopes.update(role.permissions or [])
|
if role.slug not in default_slugs:
|
||||||
|
scopes.update(role.permissions or [])
|
||||||
if include_system and user.account:
|
if include_system and user.account:
|
||||||
for role in collect_system_roles(session, user.account):
|
for role in collect_system_roles(session, user.account):
|
||||||
scopes.update(role.permissions or [])
|
scopes.update(role.permissions or [])
|
||||||
return expand_scopes(scopes)
|
return expand_scopes(scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_authenticated_slugs() -> set[str]:
|
||||||
|
return {
|
||||||
|
template.slug
|
||||||
|
for template in role_templates_for_level("tenant")
|
||||||
|
if template.default_authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_authenticated_scopes() -> set[str]:
|
||||||
|
return {
|
||||||
|
scope
|
||||||
|
for template in role_templates_for_level("tenant")
|
||||||
|
if template.default_authenticated
|
||||||
|
for scope in template.permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _materialized_default_authenticated_roles(
|
||||||
|
session: Session,
|
||||||
|
user: User,
|
||||||
|
) -> list[Role]:
|
||||||
|
"""Return the optional database projection without mutating auth reads."""
|
||||||
|
|
||||||
|
default_slugs = _default_authenticated_slugs()
|
||||||
|
if not default_slugs:
|
||||||
|
return []
|
||||||
|
return (
|
||||||
|
session.query(Role)
|
||||||
|
.filter(
|
||||||
|
Role.tenant_id == user.tenant_id,
|
||||||
|
Role.slug.in_(default_slugs),
|
||||||
|
)
|
||||||
|
.order_by(Role.name.asc(), Role.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
|
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
|
||||||
return (
|
return (
|
||||||
session.query(User, Tenant)
|
session.query(User, Tenant)
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ class AdminBatchHelperTests(unittest.TestCase):
|
|||||||
self.engine = create_engine("sqlite:///:memory:")
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(bind=self.engine)
|
Base.metadata.create_all(bind=self.engine)
|
||||||
self.Session = sessionmaker(bind=self.engine)
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
Base.metadata.drop_all(bind=self.engine)
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
def test_access_admin_batch_maps_related_rows(self) -> None:
|
def test_access_admin_batch_maps_related_rows(self) -> None:
|
||||||
session = self.Session()
|
session = self.session
|
||||||
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
|
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
|
||||||
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
|
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
|
||||||
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
|
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
|
||||||
|
|||||||
137
tests/test_default_authenticated_roles.py
Normal file
137
tests/test_default_authenticated_roles.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.admin.service import ensure_default_roles, set_user_roles
|
||||||
|
from govoplan_access.backend.db.models import Account, Role, User, UserRoleAssignment
|
||||||
|
from govoplan_access.backend.security.sessions import collect_user_authorization_context
|
||||||
|
from govoplan_core.core.modules import RoleTemplate
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
DOCS_READER = RoleTemplate(
|
||||||
|
slug="docs_reader",
|
||||||
|
name="Documentation reader",
|
||||||
|
description="Authenticated documentation baseline.",
|
||||||
|
permissions=("docs:documentation:read",),
|
||||||
|
default_authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultAuthenticatedRoleTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||||
|
self.session.add(self.tenant)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _add_user(self, suffix: str) -> User:
|
||||||
|
account = Account(
|
||||||
|
id=f"account-{suffix}",
|
||||||
|
email=f"{suffix}@example.test",
|
||||||
|
normalized_email=f"{suffix}@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id=f"user-{suffix}",
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
)
|
||||||
|
self.session.add_all([account, user])
|
||||||
|
self.session.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def test_materialized_default_role_is_implicit_and_cannot_be_assigned_or_removed(self) -> None:
|
||||||
|
first = self._add_user("first")
|
||||||
|
self._add_user("second")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.admin.service.role_templates_for_level",
|
||||||
|
return_value=(DOCS_READER,),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||||
|
return_value=(DOCS_READER,),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
roles = ensure_default_roles(self.session, self.tenant)
|
||||||
|
default_role = roles["docs_reader"]
|
||||||
|
self.assertFalse(default_role.is_assignable)
|
||||||
|
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||||
|
|
||||||
|
# The database row is only an administration projection. Even if
|
||||||
|
# stale or tampered, it must never broaden the manifest baseline.
|
||||||
|
default_role.permissions = ["mail:profile:write"]
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
set_user_roles(self.session, user=first, role_ids=[])
|
||||||
|
context = collect_user_authorization_context(
|
||||||
|
self.session,
|
||||||
|
first,
|
||||||
|
account=first.account,
|
||||||
|
include_system=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||||
|
self.assertIn("docs:documentation:read", context.scopes)
|
||||||
|
self.assertNotIn("mail:profile:write", context.scopes)
|
||||||
|
self.assertEqual([role.slug for role in context.tenant_roles], ["docs_reader"])
|
||||||
|
|
||||||
|
def test_first_authorized_request_grants_default_without_database_mutation(self) -> None:
|
||||||
|
user = self._add_user("new")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||||
|
return_value=(DOCS_READER,),
|
||||||
|
):
|
||||||
|
context = collect_user_authorization_context(
|
||||||
|
self.session,
|
||||||
|
user,
|
||||||
|
account=user.account,
|
||||||
|
include_system=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("docs:documentation:read", context.scopes)
|
||||||
|
self.assertEqual(context.tenant_roles, [])
|
||||||
|
self.assertEqual(self.session.query(Role).count(), 0)
|
||||||
|
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||||
|
self.assertFalse(self.session.new)
|
||||||
|
self.assertFalse(self.session.dirty)
|
||||||
|
|
||||||
|
user_id = user.id
|
||||||
|
self.session.commit()
|
||||||
|
self.session.close()
|
||||||
|
self.session = self.Session()
|
||||||
|
persisted_user = self.session.get(User, user_id)
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||||
|
return_value=(DOCS_READER,),
|
||||||
|
):
|
||||||
|
reopened_context = collect_user_authorization_context(
|
||||||
|
self.session,
|
||||||
|
persisted_user,
|
||||||
|
account=persisted_user.account,
|
||||||
|
include_system=False,
|
||||||
|
)
|
||||||
|
self.assertIn("docs:documentation:read", reopened_context.scopes)
|
||||||
|
self.assertEqual(self.session.query(Role).count(), 0)
|
||||||
|
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
87
tests/test_login_security.py
Normal file
87
tests/test_login_security.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1 import auth
|
||||||
|
from govoplan_access.backend.security.passwords import (
|
||||||
|
DUMMY_PASSWORD_HASH,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from govoplan_core.api.v1.schemas import LoginRequest
|
||||||
|
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
account
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def test_absent_identity_verifies_a_valid_dummy_hash_before_generic_failure(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.assertTrue(verify_password("not-a-user-password", DUMMY_PASSWORD_HASH))
|
||||||
|
payload = LoginRequest(
|
||||||
|
email="missing@example.test", password="attempted-password"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(auth, "verify_password", return_value=True) as verifier:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_login_user(self._session_with_account(None), payload)
|
||||||
|
|
||||||
|
verifier.assert_called_once_with(payload.password, DUMMY_PASSWORD_HASH)
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
|
def test_present_identity_verifies_account_hash_before_same_generic_failure(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
|
||||||
|
account = SimpleNamespace(password_hash=account_hash)
|
||||||
|
payload = LoginRequest(email="known@example.test", password="wrong-password")
|
||||||
|
|
||||||
|
with patch.object(auth, "verify_password", return_value=False) as verifier:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
verifier.assert_called_once_with(payload.password, account_hash)
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
|
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
|
||||||
|
account = SimpleNamespace(password_hash=None)
|
||||||
|
payload = LoginRequest(
|
||||||
|
email="passwordless@example.test", password="not-a-user-password"
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
|
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
|
||||||
|
account = SimpleNamespace(
|
||||||
|
id="account-1",
|
||||||
|
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
|
||||||
|
)
|
||||||
|
session = self._session_with_account(account)
|
||||||
|
session.query.return_value.join.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||||
|
payload = LoginRequest(email="known@example.test", password="correct-password")
|
||||||
|
|
||||||
|
with patch.object(auth, "verify_password", return_value=True):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_login_user(session, payload) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
219
tests/test_login_throttle.py
Normal file
219
tests/test_login_throttle.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1 import auth
|
||||||
|
from govoplan_access.backend.security.login_throttle import (
|
||||||
|
AttemptBucket,
|
||||||
|
InMemoryLoginAttemptStore,
|
||||||
|
LoginThrottle,
|
||||||
|
LoginThrottleDecision,
|
||||||
|
ResilientLoginAttemptStore,
|
||||||
|
)
|
||||||
|
from govoplan_core.api.v1.schemas import LoginRequest
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottleTests(unittest.TestCase):
|
||||||
|
def test_identity_and_client_buckets_enforce_independent_limits(self) -> None:
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
identity_limit=2,
|
||||||
|
client_limit=3,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"normalized_email": "person@example.test",
|
||||||
|
"tenant_slug": "tenant-a",
|
||||||
|
"client_address": "192.0.2.4",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertTrue(throttle.record_failure(**context).allowed)
|
||||||
|
self.assertFalse(throttle.record_failure(**context).allowed)
|
||||||
|
|
||||||
|
other_identity = {**context, "normalized_email": "other@example.test"}
|
||||||
|
self.assertFalse(throttle.record_failure(**other_identity).allowed)
|
||||||
|
|
||||||
|
def test_success_clears_identity_bucket_without_erasing_client_failures(self) -> None:
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
identity_limit=2,
|
||||||
|
client_limit=3,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"normalized_email": "person@example.test",
|
||||||
|
"tenant_slug": "tenant-a",
|
||||||
|
"client_address": "192.0.2.8",
|
||||||
|
}
|
||||||
|
self.assertTrue(throttle.record_failure(**context).allowed)
|
||||||
|
|
||||||
|
throttle.record_success(
|
||||||
|
normalized_email=context["normalized_email"],
|
||||||
|
tenant_slug=context["tenant_slug"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(throttle.check(**context).allowed)
|
||||||
|
other_identity = {**context, "normalized_email": "other@example.test"}
|
||||||
|
self.assertTrue(throttle.record_failure(**other_identity).allowed)
|
||||||
|
self.assertFalse(throttle.record_failure(**other_identity).allowed)
|
||||||
|
|
||||||
|
def test_rotating_untrusted_tenant_slug_does_not_bypass_identity_limit(self) -> None:
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
identity_limit=2,
|
||||||
|
client_limit=100,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
throttle.record_failure(
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
tenant_slug="tenant-a",
|
||||||
|
client_address="192.0.2.1",
|
||||||
|
).allowed
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
throttle.record_failure(
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
tenant_slug="made-up-tenant",
|
||||||
|
client_address="198.51.100.9",
|
||||||
|
).allowed
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bucket_keys_do_not_contain_identity_or_client_data(self) -> None:
|
||||||
|
store = MagicMock()
|
||||||
|
store.increment.return_value = AttemptBucket(count=1, retry_after_seconds=60)
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
store,
|
||||||
|
identity_limit=10,
|
||||||
|
client_limit=100,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
throttle.record_failure(
|
||||||
|
normalized_email="private.person@example.test",
|
||||||
|
tenant_slug="private-tenant",
|
||||||
|
client_address="192.0.2.9",
|
||||||
|
)
|
||||||
|
|
||||||
|
keys = [call.args[0] for call in store.increment.call_args_list]
|
||||||
|
self.assertEqual(len(keys), 2)
|
||||||
|
for key in keys:
|
||||||
|
self.assertNotIn("private", key)
|
||||||
|
self.assertNotIn("example", key)
|
||||||
|
self.assertNotIn("192.0.2.9", key)
|
||||||
|
|
||||||
|
def test_redis_failure_uses_local_store_during_retry_window(self) -> None:
|
||||||
|
primary = MagicMock()
|
||||||
|
primary.read.side_effect = RedisError("not available")
|
||||||
|
fallback = InMemoryLoginAttemptStore()
|
||||||
|
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
|
||||||
|
|
||||||
|
with self.assertLogs(
|
||||||
|
"govoplan_access.backend.security.login_throttle",
|
||||||
|
level="WARNING",
|
||||||
|
) as captured:
|
||||||
|
self.assertEqual(store.read("bucket"), AttemptBucket())
|
||||||
|
result = store.increment("bucket", window_seconds=60)
|
||||||
|
|
||||||
|
self.assertEqual(result.count, 1)
|
||||||
|
self.assertEqual(primary.read.call_count, 1)
|
||||||
|
primary.increment.assert_not_called()
|
||||||
|
self.assertIn("process-local fallback", captured.output[0])
|
||||||
|
|
||||||
|
def test_redis_recovery_does_not_erase_failures_counted_by_the_fallback(self) -> None:
|
||||||
|
primary = MagicMock()
|
||||||
|
primary.read.side_effect = [RedisError("not available"), AttemptBucket(count=1, retry_after_seconds=30)]
|
||||||
|
primary.increment.return_value = AttemptBucket(count=2, retry_after_seconds=30)
|
||||||
|
fallback = InMemoryLoginAttemptStore()
|
||||||
|
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
|
||||||
|
|
||||||
|
with self.assertLogs("govoplan_access.backend.security.login_throttle", level="WARNING"):
|
||||||
|
store.read("bucket")
|
||||||
|
store.increment("bucket", window_seconds=60)
|
||||||
|
store.increment("bucket", window_seconds=60)
|
||||||
|
|
||||||
|
store._primary_unavailable_until = 0 # Simulate the next Redis retry window.
|
||||||
|
recovered = store.read("bucket")
|
||||||
|
incremented = store.increment("bucket", window_seconds=60)
|
||||||
|
|
||||||
|
self.assertEqual(recovered.count, 2)
|
||||||
|
self.assertEqual(incremented.count, 3)
|
||||||
|
|
||||||
|
def test_in_memory_store_is_bounded(self) -> None:
|
||||||
|
store = InMemoryLoginAttemptStore(max_entries=2)
|
||||||
|
for key in ("one", "two", "three"):
|
||||||
|
store.increment(key, window_seconds=60)
|
||||||
|
|
||||||
|
active = sum(store.read(key).count > 0 for key in ("one", "two", "three"))
|
||||||
|
self.assertEqual(active, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottleRouteTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.payload = LoginRequest(
|
||||||
|
email="Person@Example.Test",
|
||||||
|
password="attempt",
|
||||||
|
tenant_slug="tenant-a",
|
||||||
|
)
|
||||||
|
self.request = SimpleNamespace(client=SimpleNamespace(host="192.0.2.10"))
|
||||||
|
|
||||||
|
def test_throttled_identity_gets_same_generic_failure_detail(self) -> None:
|
||||||
|
throttle = MagicMock()
|
||||||
|
throttle.check.return_value = LoginThrottleDecision(False, 42)
|
||||||
|
|
||||||
|
with patch.object(auth, "_configured_login_throttle", return_value=throttle):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 429)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
self.assertEqual(raised.exception.headers, {"Retry-After": "42"})
|
||||||
|
|
||||||
|
def test_failed_login_is_counted_and_threshold_response_stays_generic(self) -> None:
|
||||||
|
throttle = MagicMock()
|
||||||
|
throttle.check.return_value = LoginThrottleDecision(True)
|
||||||
|
throttle.record_failure.return_value = LoginThrottleDecision(False, 60)
|
||||||
|
generic_failure = HTTPException(status_code=401, detail="Invalid login")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(auth, "_configured_login_throttle", return_value=throttle),
|
||||||
|
patch.object(auth, "_resolve_login_user", side_effect=generic_failure),
|
||||||
|
):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 429)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
throttle.record_failure.assert_called_once_with(
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
tenant_slug="tenant-a",
|
||||||
|
client_address="192.0.2.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_success_clears_only_the_identity_bucket(self) -> None:
|
||||||
|
throttle = MagicMock()
|
||||||
|
throttle.check.return_value = LoginThrottleDecision(True)
|
||||||
|
resolved = (object(), object(), object())
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(auth, "_configured_login_throttle", return_value=throttle),
|
||||||
|
patch.object(auth, "_resolve_login_user", return_value=resolved),
|
||||||
|
):
|
||||||
|
result = auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(result, resolved)
|
||||||
|
throttle.record_failure.assert_not_called()
|
||||||
|
throttle.record_success.assert_called_once_with(
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
tenant_slug="tenant-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -15,7 +15,7 @@ class OptionalTenancyContractTests(unittest.TestCase):
|
|||||||
|
|
||||||
dependencies = tuple(project["dependencies"])
|
dependencies = tuple(project["dependencies"])
|
||||||
|
|
||||||
self.assertIn("govoplan-core>=0.1.8", dependencies)
|
self.assertIn("govoplan-core>=0.1.11", dependencies)
|
||||||
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
||||||
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||||
|
|
||||||
|
|||||||
95
tests/test_people_search.py
Normal file
95
tests/test_people_search.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, User
|
||||||
|
from govoplan_access.backend.manifest import manifest
|
||||||
|
from govoplan_access.backend.people_search import AccessPeopleSearchProvider
|
||||||
|
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH, PeopleSearchError, PeopleSearchProvider
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AccessPeopleSearchTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.provider = AccessPeopleSearchProvider()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _add_user(
|
||||||
|
self,
|
||||||
|
suffix: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
display_name: str | None = None,
|
||||||
|
user_active: bool = True,
|
||||||
|
account_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
email = f"{suffix}@example.test"
|
||||||
|
account = Account(
|
||||||
|
id=f"account-{suffix}",
|
||||||
|
email=email,
|
||||||
|
normalized_email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
is_active=account_active,
|
||||||
|
)
|
||||||
|
self.session.add_all([
|
||||||
|
account,
|
||||||
|
User(
|
||||||
|
id=f"user-{suffix}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
is_active=user_active,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def test_search_is_tenant_bounded_and_excludes_inactive_records(self) -> None:
|
||||||
|
self._add_user("ada", display_name="Ada Lovelace")
|
||||||
|
self._add_user("other", tenant_id="tenant-2", display_name="Ada Other Tenant")
|
||||||
|
self._add_user("inactive-user", display_name="Ada Inactive User", user_active=False)
|
||||||
|
self._add_user("inactive-account", display_name="Ada Inactive Account", account_active=False)
|
||||||
|
|
||||||
|
groups = self.provider.search_people(
|
||||||
|
self.session,
|
||||||
|
SimpleNamespace(tenant_id="tenant-1"),
|
||||||
|
query="ada",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([group.key for group in groups], ["accounts"])
|
||||||
|
self.assertEqual([item.reference_id for item in groups[0].candidates], ["account-ada"])
|
||||||
|
self.assertEqual(groups[0].candidates[0].email, "ada@example.test")
|
||||||
|
self.assertNotIn("tenant_id", groups[0].candidates[0].metadata)
|
||||||
|
|
||||||
|
def test_search_escapes_like_wildcards_and_requires_tenant_context(self) -> None:
|
||||||
|
self._add_user("ada", display_name="Ada Lovelace")
|
||||||
|
|
||||||
|
groups = self.provider.search_people(
|
||||||
|
self.session,
|
||||||
|
SimpleNamespace(tenant_id="tenant-1"),
|
||||||
|
query="%",
|
||||||
|
)
|
||||||
|
self.assertEqual(groups[0].candidates, ())
|
||||||
|
with self.assertRaises(PeopleSearchError):
|
||||||
|
self.provider.search_people(self.session, SimpleNamespace(tenant_id=None), query="ada")
|
||||||
|
|
||||||
|
def test_manifest_exposes_the_shared_interface(self) -> None:
|
||||||
|
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, manifest.capability_factories)
|
||||||
|
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
|
||||||
|
self.assertIsInstance(self.provider, PeopleSearchProvider)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -24,6 +24,12 @@ class PermissionCatalogContractTests(unittest.TestCase):
|
|||||||
self.assertIn("files:upload", scopes)
|
self.assertIn("files:upload", scopes)
|
||||||
self.assertIn("mail_servers:test", scopes)
|
self.assertIn("mail_servers:test", scopes)
|
||||||
|
|
||||||
|
def test_expand_scopes_preserves_explicit_canonical_scope_with_legacy_alias(self) -> None:
|
||||||
|
scopes = access_catalog.expand_scopes(("files:file:read",))
|
||||||
|
|
||||||
|
self.assertIn("files:file:read", scopes)
|
||||||
|
self.assertIn("files:read", scopes)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy, TenantAdminItem } from "@govoplan/core-webui";
|
import type {
|
||||||
import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui";
|
AccessDecisionProvenanceItem as CoreAccessDecisionProvenanceItem,
|
||||||
|
ApiSettings,
|
||||||
|
DeltaDeletedItem,
|
||||||
|
PrivacyRetentionPolicy,
|
||||||
|
ResourceAccessExplanationOptions,
|
||||||
|
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
|
||||||
|
TenantAdminItem
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||||
export type {
|
export type {
|
||||||
AdminOverview,
|
AdminOverview,
|
||||||
@@ -101,12 +109,7 @@ export type AccessScopeExplanationItem = {
|
|||||||
sources: AccessRoleSourceItem[];
|
sources: AccessRoleSourceItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccessDecisionProvenanceItem = {
|
export type AccessDecisionProvenanceItem = Omit<CoreAccessDecisionProvenanceItem, "details"> & {
|
||||||
kind: string;
|
|
||||||
id?: string | null;
|
|
||||||
label?: string | null;
|
|
||||||
tenant_id?: string | null;
|
|
||||||
source?: string | null;
|
|
||||||
details: Record<string, unknown>;
|
details: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,13 +139,7 @@ export type UserAccessExplanationResponse = {
|
|||||||
function_facts: FunctionFactExplanationItem[];
|
function_facts: FunctionFactExplanationItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResourceAccessExplanationResponse = {
|
export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationResponse<UserAdminItem, AccessDecisionProvenanceItem>;
|
||||||
user: UserAdminItem;
|
|
||||||
resource_type: string;
|
|
||||||
resource_id: string;
|
|
||||||
action: string;
|
|
||||||
provenance: AccessDecisionProvenanceItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SystemAccountItem = {
|
export type SystemAccountItem = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -356,15 +353,9 @@ export function fetchUserAccessExplanation(settings: ApiSettings, userId: string
|
|||||||
|
|
||||||
export function fetchResourceAccessExplanation(
|
export function fetchResourceAccessExplanation(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null }
|
options: ResourceAccessExplanationOptions
|
||||||
): Promise<ResourceAccessExplanationResponse> {
|
): Promise<ResourceAccessExplanationResponse> {
|
||||||
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", {
|
return fetchCoreResourceAccessExplanation<UserAdminItem, AccessDecisionProvenanceItem>(settings, options);
|
||||||
user_id: options.userId,
|
|
||||||
resource_type: options.resourceType,
|
|
||||||
resource_id: options.resourceId,
|
|
||||||
action: options.action,
|
|
||||||
tenant_id: options.tenantId
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
@@ -8,8 +8,9 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { DateTimeField } from "@govoplan/core-webui";
|
import { DateTimeField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||||
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
@@ -99,11 +100,10 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
|||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
|
||||||
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
||||||
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} disabled />
|
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.revoked_at, disabled: !canRevoke, onClick: () => setRevoking(row) }
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
|
]} /> }],
|
||||||
</div> }],
|
|
||||||
[canRevoke]);
|
[canRevoke]);
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -151,7 +151,7 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
|||||||
|
|
||||||
return (
|
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} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> i18n:govoplan-access.show_revoked.b4265807</label><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
|
<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} actions={<><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} onChange={setShowRevoked} /><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
|
||||||
<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>
|
<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>
|
</AdminPageLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
|||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||||
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
@@ -257,12 +257,10 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
sticky: "end",
|
sticky: "end",
|
||||||
resizable: false,
|
resizable: false,
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (row) => (
|
render: (row) => <TableActionGroup actions={[
|
||||||
<div className="admin-icon-actions">
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, onClick: () => setDeleting(row) }
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
|
]} />
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[auth, canWrite, functionPicker, roleById, settings]
|
[auth, canWrite, functionPicker, roleById, settings]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
||||||
@@ -127,15 +127,15 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
||||||
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
|
||||||
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
||||||
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), onClick: () => setDeactivating(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[canAssignRoles, canDefine, canManageMembers]);
|
[canAssignRoles, canDefine, canManageMembers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
|
||||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
@@ -78,10 +78,12 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
|||||||
setDraft(emptyDraft);
|
setDraft(emptyDraft);
|
||||||
setSavedDraftKey(draftKey(emptyDraft));
|
setSavedDraftKey(draftKey(emptyDraft));
|
||||||
}
|
}
|
||||||
function togglePermission(scope: string, checked: boolean) {
|
function setPermissionGroup(scopes: string[], selected: string[]) {
|
||||||
const next = new Set(draft.permissions);
|
const groupScopes = new Set(scopes);
|
||||||
if (checked) next.add(scope);else next.delete(scope);
|
setDraft({
|
||||||
setDraft({ ...draft, permissions: Array.from(next) });
|
...draft,
|
||||||
|
permissions: [...draft.permissions.filter((scope) => !groupScopes.has(scope)), ...selected]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(): Promise<boolean> {
|
async function save(): Promise<boolean> {
|
||||||
@@ -118,15 +120,15 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||||
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
|
||||||
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
||||||
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
||||||
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, onClick: () => setDeleting(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[canDefine, permissions]);
|
[canDefine, permissions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,7 +144,10 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
|||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
|
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
|
||||||
|
const scopes = items.map((permission) => permission.scope);
|
||||||
|
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
|
||||||
|
})}</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
|||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
@@ -223,11 +223,11 @@ export default function SystemRolesPanel({
|
|||||||
align: "right",
|
align: "right",
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const protectedOwner = row.slug === "system_owner";
|
const protectedOwner = row.slug === "system_owner";
|
||||||
return <div className="admin-icon-actions">
|
return <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, onClick: () => setDeleting(row) }
|
||||||
</div>;
|
]} />;
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
[canWrite]);
|
[canWrite]);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
type SystemMembershipDraft,
|
type SystemMembershipDraft,
|
||||||
type TenantAdminItem
|
type TenantAdminItem
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
email: "",
|
email: "",
|
||||||
@@ -149,16 +149,13 @@ export default function SystemUsersPanel({
|
|||||||
setSavedDraftKey(draftKey(emptyDraft));
|
setSavedDraftKey(draftKey(emptyDraft));
|
||||||
}
|
}
|
||||||
|
|
||||||
function membership(tenantId: string) {
|
function setMembershipSelection(tenantIds: string[]) {
|
||||||
return draft.memberships.find((item) => item.tenant_id === tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMembership(tenantId: string, enabled: boolean) {
|
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
memberships: enabled ?
|
memberships: tenantIds.map((tenantId) =>
|
||||||
[...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] :
|
current.memberships.find((item) => item.tenant_id === tenantId) ??
|
||||||
current.memberships.filter((item) => item.tenant_id !== tenantId)
|
{ tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }
|
||||||
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,11 +212,11 @@ export default function SystemUsersPanel({
|
|||||||
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
{ 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: "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) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
|
{ 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), onClick: () => setDeactivating(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -253,7 +250,7 @@ export default function SystemUsersPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-assignment-grid">
|
<div className="admin-assignment-grid">
|
||||||
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||||
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
|
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
||||||
</div>
|
</div>
|
||||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
||||||
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from "@govoplan/core-webui";
|
|||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
|
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
|
||||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const DELTA_KEY = "access:tenant-settings";
|
const DELTA_KEY = "access:tenant-settings";
|
||||||
|
|
||||||
@@ -94,10 +94,8 @@ export default function TenantSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLanguage(code: string, checked: boolean) {
|
function setEnabledLanguages(selected: string[]) {
|
||||||
const enabled = new Set(draft.enabled_language_codes);
|
const enabled = new Set(selected);
|
||||||
if (checked) enabled.add(code);
|
|
||||||
else enabled.delete(code);
|
|
||||||
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
|
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
|
||||||
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
||||||
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
||||||
@@ -122,21 +120,14 @@ export default function TenantSettingsPanel({
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="settings-list">
|
<AdminSelectionList
|
||||||
{draft.system_enabled_language_codes.map((code) => {
|
options={draft.system_enabled_language_codes.map((code) => {
|
||||||
const language = draft.available_languages.find((item) => item.code === code);
|
const language = draft.available_languages.find((item) => item.code === code);
|
||||||
return (
|
return { id: code, label: code.toUpperCase(), description: languageOptionLabel(language ?? { code, label: code.toUpperCase() }), disabled: !canWrite || busy || code === draft.default_locale };
|
||||||
<label className="admin-inline-check" key={code}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={draft.enabled_language_codes.includes(code)}
|
|
||||||
disabled={!canWrite || busy || code === draft.default_locale}
|
|
||||||
onChange={(event) => toggleLanguage(code, event.target.checked)} />
|
|
||||||
<span><strong>{code.toUpperCase()}</strong> {languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
selected={draft.enabled_language_codes}
|
||||||
|
onChange={setEnabledLanguages}
|
||||||
|
/>
|
||||||
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
|
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
type OverrideValue = "inherit" | "allow" | "deny";
|
type OverrideValue = "inherit" | "allow" | "deny";
|
||||||
@@ -225,11 +225,11 @@ export default function TenantsPanel({
|
|||||||
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
||||||
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canUpdate, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
|
{ id: "suspend", label: i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.id === activeTenantId, onClick: () => setConfirmSuspend(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[activeTenantId, canSuspend, canUpdate]);
|
[activeTenantId, canSuspend, canUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { PasswordField } from "@govoplan/core-webui";
|
import { PasswordField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
@@ -186,12 +187,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
|
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
|
||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
||||||
{ 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: "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: 190, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email })} icon={<KeyRound />} onClick={() => void openAccessExplanation(row)} />
|
{ id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, onClick: () => setDeactivating(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +217,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
}
|
}
|
||||||
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||||
</div>
|
</div>
|
||||||
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3</label>}
|
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
||||||
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
||||||
<div className="admin-assignment-grid">
|
<div className="admin-assignment-grid">
|
||||||
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}",
|
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}",
|
||||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||||
|
"i18n:govoplan-access.available.7c62a142": "Available",
|
||||||
"i18n:govoplan-access.file_connections.1e362326": "File connections",
|
"i18n:govoplan-access.file_connections.1e362326": "File connections",
|
||||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
|
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
|
||||||
"i18n:govoplan-access.files.6ce6c512": "Files",
|
"i18n:govoplan-access.files.6ce6c512": "Files",
|
||||||
@@ -119,6 +120,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
|
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
|
||||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
|
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
|
||||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
|
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
|
||||||
|
"i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Map accepted function facts to tenant roles.",
|
||||||
"i18n:govoplan-access.general.9239ee2c": "General",
|
"i18n:govoplan-access.general.9239ee2c": "General",
|
||||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||||
@@ -456,6 +458,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren",
|
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren",
|
||||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||||
|
"i18n:govoplan-access.available.7c62a142": "Verfuegbar",
|
||||||
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
|
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
|
||||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
|
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
|
||||||
"i18n:govoplan-access.files.6ce6c512": "Dateien",
|
"i18n:govoplan-access.files.6ce6c512": "Dateien",
|
||||||
@@ -467,6 +470,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
|
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
|
||||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
|
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
|
||||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
|
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
|
||||||
|
"i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Akzeptierte Funktionsfakten Mandantenrollen zuordnen.",
|
||||||
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
|
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
|
||||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ export { default } from "./module";
|
|||||||
export * from "./module";
|
export * from "./module";
|
||||||
export * from "./api/admin";
|
export * from "./api/admin";
|
||||||
export { default as AdminPage } from "./features/admin/AdminPage";
|
export { default as AdminPage } from "./features/admin/AdminPage";
|
||||||
|
export { ResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
|
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
|
||||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
|
|||||||
Reference in New Issue
Block a user