15 Commits

44 changed files with 3350 additions and 735 deletions

View File

@@ -1,5 +1,9 @@
# GovOPlaN Access # GovOPlaN Access
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-access` is the platform module for GovOPlaN identity, `govoplan-access` is the platform module for GovOPlaN identity,
authentication, sessions, API keys, RBAC, groups, users, and access authentication, sessions, API keys, RBAC, groups, users, and access
administration. administration.
@@ -14,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
@@ -98,7 +108,7 @@ available:
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply /mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
``` ```
## Development Install ## Development Install
@@ -109,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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.7", "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.7", "@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",

View File

@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.7" 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.7", "govoplan-core>=0.1.11",
"redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
] ]

View File

@@ -1,3 +1,3 @@
"""GovOPlaN access platform module.""" """GovOPlaN access platform module."""
__version__ = "0.1.6" __version__ = "0.1.11"

View File

@@ -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))

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import ( from govoplan_access.backend.admin.service import (
@@ -41,6 +44,7 @@ from govoplan_access.backend.db.models import (
Tenant, Tenant,
User, User,
UserGroupMembership, UserGroupMembership,
UserRoleAssignment,
) )
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory from govoplan_core.core.organizations import OrganizationDirectory
@@ -82,13 +86,147 @@ def _resolve_tenant(
return tenant return tenant
def _role_summary(session: Session, role: Role) -> RoleSummary: def _tenant_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, tuple[int, int]]:
if not role_ids:
return {}
user_counts = {
role_id: count
for role_id, count in session.query(UserRoleAssignment.role_id, func.count(UserRoleAssignment.id))
.filter(UserRoleAssignment.role_id.in_(role_ids))
.group_by(UserRoleAssignment.role_id)
.all()
}
group_counts = {
role_id: count
for role_id, count in session.query(GroupRoleAssignment.role_id, func.count(GroupRoleAssignment.id))
.filter(GroupRoleAssignment.role_id.in_(role_ids))
.group_by(GroupRoleAssignment.role_id)
.all()
}
return {role_id: (int(user_counts.get(role_id, 0)), int(group_counts.get(role_id, 0))) for role_id in role_ids}
def _system_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, int]:
if not role_ids:
return {}
return {
role_id: int(count)
for role_id, count in session.query(SystemRoleAssignment.role_id, func.count(SystemRoleAssignment.id))
.filter(SystemRoleAssignment.role_id.in_(role_ids))
.group_by(SystemRoleAssignment.role_id)
.all()
}
def _accounts_by_id(session: Session, account_ids: list[str]) -> dict[str, Account]:
if not account_ids:
return {}
return {
account.id: account
for account in session.query(Account).filter(Account.id.in_(sorted(set(account_ids)))).all()
}
def _accounts_by_user_id(session: Session, user_ids: list[str]) -> dict[str, Account]:
if not user_ids:
return {}
return {
user.id: account
for user, account in session.query(User, Account)
.join(Account, Account.id == User.account_id)
.filter(User.id.in_(sorted(set(user_ids))))
.all()
}
def _group_member_ids_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[str]]:
grouped: dict[str, list[str]] = defaultdict(list)
if not group_ids:
return {}
for group_id, user_id in (
session.query(UserGroupMembership.group_id, UserGroupMembership.user_id)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.group_id.in_(sorted(set(group_ids))))
.order_by(UserGroupMembership.group_id.asc(), UserGroupMembership.user_id.asc())
.all()
):
grouped[group_id].append(user_id)
return dict(grouped)
def _roles_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[Role]]:
grouped: dict[str, list[Role]] = defaultdict(list)
if not group_ids:
return {}
for group_id, role in (
session.query(GroupRoleAssignment.group_id, Role)
.join(Role, Role.id == GroupRoleAssignment.role_id)
.filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id.in_(sorted(set(group_ids))), Role.tenant_id == tenant_id)
.order_by(GroupRoleAssignment.group_id.asc(), Role.name.asc())
.all()
):
grouped[group_id].append(role)
return dict(grouped)
def _groups_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Group]]:
grouped: dict[str, list[Group]] = defaultdict(list)
if not user_ids:
return {}
for user_id, group in (
session.query(UserGroupMembership.user_id, Group)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id.in_(sorted(set(user_ids))),
Group.is_active.is_(True),
)
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
.all()
):
grouped[user_id].append(group)
return dict(grouped)
def _roles_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Role]]:
grouped: dict[str, list[Role]] = defaultdict(list)
if not user_ids:
return {}
for user_id, role in (
session.query(UserRoleAssignment.user_id, Role)
.join(Role, Role.id == UserRoleAssignment.role_id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id.in_(sorted(set(user_ids))),
Role.tenant_id == tenant_id,
)
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
.all()
):
grouped[user_id].append(role)
return dict(grouped)
def _role_summary(
session: Session,
role: Role,
*,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
system_role_assignment_counts: dict[str, int] | None = None,
) -> RoleSummary:
group_count = 0 group_count = 0
if role.tenant_id is None: if role.tenant_id is None:
user_count = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count() user_count = (
system_role_assignment_counts.get(role.id, 0)
if system_role_assignment_counts is not None
else session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
)
permission_level = "system" permission_level = "system"
else: else:
user_count, group_count = role_assignment_counts(session, role.id) user_count, group_count = (
tenant_role_assignment_counts.get(role.id, (0, 0))
if tenant_role_assignment_counts is not None
else role_assignment_counts(session, role.id)
)
permission_level = "tenant" permission_level = "tenant"
permissions = list(role.permissions or []) permissions = list(role.permissions or [])
return RoleSummary( return RoleSummary(
@@ -108,19 +246,35 @@ def _role_summary(session: Session, role: Role) -> RoleSummary:
) )
def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary: def _group_summary(
member_ids = [ session: Session,
row[0] group: Group,
for row in session.query(UserGroupMembership.user_id) *,
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id) include_members: bool = True,
.all() member_ids_by_group: dict[str, list[str]] | None = None,
] roles_by_group: dict[str, list[Role]] | None = None,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
) -> GroupSummary:
member_ids = (
member_ids_by_group.get(group.id, [])
if member_ids_by_group is not None
else [
row[0]
for row in session.query(UserGroupMembership.user_id)
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
.all()
]
)
roles = ( roles = (
session.query(Role) roles_by_group.get(group.id, [])
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id) if roles_by_group is not None
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id) else (
.order_by(Role.name.asc()) session.query(Role)
.all() .join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
.order_by(Role.name.asc())
.all()
)
) )
return GroupSummary( return GroupSummary(
id=group.id, id=group.id,
@@ -130,7 +284,7 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
is_active=group.is_active, is_active=group.is_active,
member_count=len(member_ids), member_count=len(member_ids),
member_ids=member_ids if include_members else [], member_ids=member_ids if include_members else [],
roles=[_role_summary(session, role) for role in roles], roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
created_at=group.created_at, created_at=group.created_at,
updated_at=group.updated_at, updated_at=group.updated_at,
system_template_id=group.system_template_id, system_template_id=group.system_template_id,
@@ -145,12 +299,18 @@ def _user_item(
owner_ids: set[str] | None = None, owner_ids: set[str] | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
accounts_by_id: dict[str, Account] | None = None,
groups_by_user: dict[str, list[Group]] | None = None,
roles_by_user: dict[str, list[Role]] | None = None,
group_member_ids_by_group: dict[str, list[str]] | None = None,
group_roles_by_group: dict[str, list[Role]] | None = None,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
) -> UserAdminItem: ) -> UserAdminItem:
account = session.get(Account, user.account_id) account = accounts_by_id.get(user.account_id) if accounts_by_id is not None else session.get(Account, user.account_id)
if account is None: if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = collect_user_groups(session, user) groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(session, user)
roles = collect_direct_user_roles(session, user) roles = roles_by_user.get(user.id, []) if roles_by_user is not None else collect_direct_user_roles(session, user)
external_roles = ( external_roles = (
collect_external_function_roles( collect_external_function_roles(
session, session,
@@ -176,8 +336,18 @@ def _user_item(
account_is_active=account.is_active, account_is_active=account.is_active,
password_reset_required=account.password_reset_required, password_reset_required=account.password_reset_required,
last_login_at=account.last_login_at, last_login_at=account.last_login_at,
groups=[_group_summary(session, group, include_members=False) for group in groups], groups=[
roles=[_role_summary(session, role) for role in roles], _group_summary(
session,
group,
include_members=False,
member_ids_by_group=group_member_ids_by_group,
roles_by_group=group_roles_by_group,
tenant_role_assignment_counts=tenant_role_assignment_counts,
)
for group in groups
],
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)), function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
function_delegation_ids=collect_function_delegation_ids(session, user), function_delegation_ids=collect_function_delegation_ids(session, user),
effective_scopes=expand_scopes(effective_scopes), effective_scopes=expand_scopes(effective_scopes),
@@ -226,9 +396,12 @@ def _system_account_item(session: Session, account: Account) -> SystemAccountIte
) )
def _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem: def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
user = session.get(User, item.user_id) if accounts_by_user_id is not None:
account = session.get(Account, user.account_id) if user else None account = accounts_by_user_id.get(item.user_id)
else:
user = session.get(User, item.user_id)
account = session.get(Account, user.account_id) if user else None
return ApiKeyAdminItem( return ApiKeyAdminItem(
id=item.id, id=item.id,
user_id=item.user_id, user_id=item.user_id,

View File

@@ -3,41 +3,10 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PermissionItem(BaseModel): class PermissionItem(BaseModel):
@@ -98,6 +67,13 @@ class TenantOwnerCandidateListResponse(BaseModel):
accounts: list[TenantOwnerCandidate] accounts: list[TenantOwnerCandidate]
class PagedListResponse(BaseModel):
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class TenantCreateRequest(BaseModel): class TenantCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -179,7 +155,7 @@ class IdentityAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class IdentityListResponse(BaseModel): class IdentityListResponse(PagedListResponse):
identities: list[IdentityAdminItem] identities: list[IdentityAdminItem]
@@ -529,7 +505,7 @@ class ResourceAccessExplanationResponse(BaseModel):
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list) provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
class UserListResponse(BaseModel): class UserListResponse(PagedListResponse):
users: list[UserAdminItem] users: list[UserAdminItem]
@@ -567,7 +543,7 @@ class UserUpdateRequest(BaseModel):
role_ids: list[str] | None = None role_ids: list[str] | None = None
class GroupListResponse(BaseModel): class GroupListResponse(PagedListResponse):
groups: list[GroupSummary] groups: list[GroupSummary]
@@ -599,7 +575,7 @@ class GroupUpdateRequest(BaseModel):
role_ids: list[str] | None = None role_ids: list[str] | None = None
class RoleListResponse(BaseModel): class RoleListResponse(PagedListResponse):
roles: list[RoleSummary] roles: list[RoleSummary]
@@ -638,7 +614,7 @@ class SystemAccountItem(BaseModel):
last_login_at: datetime | None = None last_login_at: datetime | None = None
class SystemAccountListResponse(BaseModel): class SystemAccountListResponse(PagedListResponse):
accounts: list[SystemAccountItem] accounts: list[SystemAccountItem]
roles: list[RoleSummary] roles: list[RoleSummary]
@@ -704,42 +680,6 @@ class PolicySourceStepItem(BaseModel):
policy: dict[str, Any] = Field(default_factory=dict) policy: dict[str, Any] = Field(default_factory=dict)
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyRetentionPolicyScopeRequest(BaseModel): class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -774,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
@@ -912,7 +852,7 @@ class ApiKeyAdminItem(BaseModel):
created_at: datetime created_at: datetime
class ApiKeyListResponse(BaseModel): class ApiKeyListResponse(PagedListResponse):
api_keys: list[ApiKeyAdminItem] api_keys: list[ApiKeyAdminItem]

View File

@@ -1,9 +1,18 @@
from __future__ import annotations from __future__ import annotations
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
from govoplan_core.api.v1.schemas import ( from govoplan_core.api.v1.schemas import (
AuthGroupsResponse,
AuthProfileResponse,
AuthRolesResponse,
AuthShellResponse,
AuthSessionResponse,
AuthSessionUserInfo,
GroupInfo, GroupInfo,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
@@ -22,9 +31,9 @@ from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityD
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.admin.settings import get_system_settings from govoplan_core.admin.settings import get_system_settings
from govoplan_core.audit.logging import audit_from_principal from govoplan_core.audit.logging import audit_event
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_access.backend.db.models import Account, Tenant, User from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Group, Role, Tenant, User
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_core.i18n import ( from govoplan_core.i18n import (
i18n_settings, i18n_settings,
@@ -36,24 +45,44 @@ from govoplan_core.i18n import (
tenant_enabled_language_codes, tenant_enabled_language_codes,
user_enabled_language_codes, user_enabled_language_codes,
) )
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings 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.passwords import verify_password from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
from govoplan_access.backend.security.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,
collect_user_authorization_context,
collect_system_roles, collect_system_roles,
collect_tenant_memberships, collect_tenant_memberships,
collect_user_groups, collect_user_groups,
collect_user_roles, collect_user_roles,
collect_user_scopes, collect_user_scopes,
create_auth_session, create_auth_session,
verify_auth_session_csrf,
) )
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@dataclass(slots=True)
class AuthContext:
account: Account
user: User
tenant: Tenant
auth_method: str
source: str
auth_session: AuthSession | None = None
api_key: ApiKey | None = None
def _cookie_samesite() -> str: def _cookie_samesite() -> str:
value = settings.auth_cookie_samesite.lower().strip() value = settings.auth_cookie_samesite.lower().strip()
if value not in {"lax", "strict", "none"}: if value not in {"lax", "strict", "none"}:
@@ -125,6 +154,18 @@ def _user_info(
) )
def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo:
return AuthSessionUserInfo(
id=user.id,
account_id=account.id,
email=account.email,
display_name=account.display_name or user.display_name,
tenant_display_name=user.display_name,
is_tenant_admin=user.is_tenant_admin,
password_reset_required=account.password_reset_required,
)
def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]: def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]:
return [ return [
RoleInfo( RoleInfo(
@@ -142,10 +183,20 @@ def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups] return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]: def _tenant_memberships(
session: Session,
account: Account,
*,
active_user_id: str | None = None,
active_tenant_roles: list[Role] | None = None,
) -> list[TenantMembershipInfo]:
memberships: list[TenantMembershipInfo] = [] memberships: list[TenantMembershipInfo] = []
for user, tenant in collect_tenant_memberships(session, account): for user, tenant in collect_tenant_memberships(session, account):
roles = collect_user_roles(session, user) roles = (
active_tenant_roles
if active_tenant_roles is not None and user.id == active_user_id
else collect_user_roles(session, user)
)
memberships.append( memberships.append(
TenantMembershipInfo( TenantMembershipInfo(
id=tenant.id, id=tenant.id,
@@ -159,6 +210,20 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember
return memberships return memberships
def _tenant_membership_summaries(session: Session, account: Account) -> list[TenantMembershipInfo]:
return [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[],
)
for user, tenant in collect_tenant_memberships(session, account)
]
def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]: def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]:
system_settings = get_system_settings(session) system_settings = get_system_settings(session)
system_payload = system_i18n_payload(system_settings) system_payload = system_i18n_payload(system_settings)
@@ -194,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 = (
@@ -210,10 +277,321 @@ 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]:
x_api_key = request.headers.get("x-api-key")
if x_api_key:
return x_api_key.strip(), "api_key"
authorization = request.headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip(), "bearer"
cookie_token = request.cookies.get(settings.auth_session_cookie_name)
if cookie_token:
return cookie_token.strip(), "cookie"
return None, "none"
def _active_context_or_401(
*,
user: User | None,
account: Account | None,
tenant: Tenant | None,
expected_tenant_id: str,
) -> tuple[Account, User, Tenant]:
if (
not user or not account or not tenant
or not user.is_active or not account.is_active or not tenant.is_active
or user.account_id != account.id
or user.tenant_id != expected_tenant_id
or tenant.id != expected_tenant_id
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session")
return account, user, tenant
def _session_response(
*,
account: Account,
user: User,
tenant: Tenant,
auth_method: str,
auth_session: AuthSession | None = None,
api_key: ApiKey | None = None,
) -> AuthSessionResponse:
active_tenant = _tenant_info(tenant)
return AuthSessionResponse(
authenticated=True,
auth_method=auth_method, # type: ignore[arg-type]
user=_session_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
session_id=auth_session.id if auth_session else None,
api_key_id=api_key.id if api_key else None,
expires_at=auth_session.expires_at if auth_session else api_key.expires_at if api_key else None,
)
def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
token, source = _extract_auth_token(request)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
api_key = authenticate_api_key(session, token) if source != "cookie" else None
if api_key is not None:
user = session.get(User, api_key.user_id)
account = session.get(Account, user.account_id) if user else None
tenant = session.get(Tenant, api_key.tenant_id)
account, user, tenant = _active_context_or_401(
user=user,
account=account,
tenant=tenant,
expected_tenant_id=api_key.tenant_id,
)
return AuthContext(
account=account,
user=user,
tenant=tenant,
auth_method="api_key",
source=source,
api_key=api_key,
)
auth_session = authenticate_session_token(session, token)
if auth_session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
user = session.get(User, auth_session.user_id)
account = session.get(Account, auth_session.account_id)
tenant = session.get(Tenant, auth_session.tenant_id)
account, user, tenant = _active_context_or_401(
user=user,
account=account,
tenant=tenant,
expected_tenant_id=auth_session.tenant_id,
)
return AuthContext(
account=account,
user=user,
tenant=tenant,
auth_method="session",
source=source,
auth_session=auth_session,
)
def _shell_response(
session: Session,
*,
account: Account,
user: User,
tenant: Tenant,
scopes: list[str],
auth_method: str,
auth_session: AuthSession | None = None,
api_key: ApiKey | None = None,
) -> AuthShellResponse:
active_tenant = _tenant_info(tenant)
memberships = (
_tenant_membership_summaries(session, account)
if auth_session is not None
else [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[],
)
]
)
return AuthShellResponse(
user=_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=memberships,
scopes=scopes,
principal=PrincipalContextInfo(
account_id=account.id,
membership_id=user.id,
tenant_id=tenant.id,
scopes=scopes,
auth_method=auth_method, # type: ignore[arg-type]
api_key_id=api_key.id if api_key else None,
session_id=auth_session.id if auth_session else None,
email=account.email,
display_name=account.display_name or user.display_name,
),
)
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
context = _resolve_auth_context(request, session)
return _session_response(
account=context.account,
user=context.user,
tenant=context.tenant,
auth_method=context.auth_method,
auth_session=context.auth_session,
api_key=context.api_key,
)
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
context = _resolve_auth_context(request, session)
if context.api_key is not None:
user_scopes = collect_user_scopes(session, context.user, include_system=False)
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
return _shell_response(
session,
account=context.account,
user=context.user,
tenant=context.tenant,
scopes=scopes,
auth_method="api_key",
api_key=context.api_key,
)
scopes = collect_user_scopes(session, context.user, include_system=True)
return _shell_response(
session,
account=context.account,
user=context.user,
tenant=context.tenant,
scopes=scopes,
auth_method="session",
auth_session=context.auth_session,
)
def _profile_response(session: Session, context: AuthContext) -> AuthProfileResponse:
languages = _language_context(session, tenant=context.tenant, user=context.user)
tenant_enabled = list(languages["tenant_enabled_language_codes"])
user_enabled = list(languages["user_enabled_language_codes"])
preferred_language = str(languages["preferred_language"])
active_tenant = _tenant_info(context.tenant, enabled_language_codes=tenant_enabled)
return AuthProfileResponse(
user=_user_info(
context.user,
context.account,
preferred_language=preferred_language,
enabled_language_codes=user_enabled,
),
tenant=active_tenant,
active_tenant=active_tenant,
available_languages=languages["available_languages"],
enabled_language_codes=tenant_enabled,
default_language=preferred_language,
)
def _roles_response(session: Session, context: AuthContext) -> AuthRolesResponse:
tenant_roles = collect_user_roles(session, context.user)
system_roles = collect_system_roles(session, context.account) if context.auth_session is not None else []
return AuthRolesResponse(roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"))
def _groups_response(session: Session, context: AuthContext) -> AuthGroupsResponse:
return AuthGroupsResponse(groups=_groups_info(collect_user_groups(session, context.user)))
def _verify_profile_mutation_allowed(request: Request, context: AuthContext) -> None:
if context.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
if context.source != "cookie":
return
header_token = request.headers.get("x-csrf-token")
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(context.auth_session, header_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
def _roles_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> tuple[list[Role], list[Role]]:
roles = (
session.query(Role)
.filter(Role.id.in_(sorted(principal.role_ids)))
.order_by(Role.name.asc())
.all()
)
tenant_roles = [role for role in roles if role.tenant_id == tenant_id]
system_roles = [role for role in roles if role.tenant_id is None and principal.auth_session is not None]
return tenant_roles, system_roles
def _groups_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> list[Group]:
return (
session.query(Group)
.filter(Group.tenant_id == tenant_id, Group.id.in_(sorted(principal.group_ids)))
.order_by(Group.name.asc())
.all()
)
def _me_response( def _me_response(
session: Session, session: Session,
*, *,
@@ -229,17 +607,42 @@ def _me_response(
include_all_memberships: bool = True, include_all_memberships: bool = True,
identity_directory: IdentityDirectory | None = None, identity_directory: IdentityDirectory | None = None,
identity_id: str | None = None, identity_id: str | None = None,
tenant_roles: list[Role] | None = None,
system_roles: list[Role] | None = None,
groups: list[Group] | None = None,
function_assignment_ids: tuple[str, ...] | None = None,
delegation_ids: tuple[str, ...] | None = None,
) -> MeResponse: ) -> MeResponse:
tenant_roles = collect_user_roles(session, user) if tenant_roles is None:
system_roles = collect_system_roles(session, account) if include_system else [] tenant_roles = collect_user_roles(session, user)
groups = collect_user_groups(session, user) if system_roles is None:
system_roles = collect_system_roles(session, account) if include_system else []
elif not include_system:
system_roles = []
if groups is None:
groups = collect_user_groups(session, user)
scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system) scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system)
resolved_function_assignment_ids = (
function_assignment_ids
if function_assignment_ids is not None
else tuple(collect_function_assignment_ids(session, user))
)
resolved_delegation_ids = (
delegation_ids
if delegation_ids is not None
else tuple(collect_function_delegation_ids(session, user))
)
languages = _language_context(session, tenant=tenant, user=user) languages = _language_context(session, tenant=tenant, user=user)
tenant_enabled = list(languages["tenant_enabled_language_codes"]) tenant_enabled = list(languages["tenant_enabled_language_codes"])
user_enabled = list(languages["user_enabled_language_codes"]) user_enabled = list(languages["user_enabled_language_codes"])
preferred_language = str(languages["preferred_language"]) preferred_language = str(languages["preferred_language"])
active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled) active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled)
memberships = _tenant_memberships(session, account) if include_all_memberships else [ memberships = _tenant_memberships(
session,
account,
active_user_id=user.id,
active_tenant_roles=tenant_roles,
) if include_all_memberships else [
TenantMembershipInfo( TenantMembershipInfo(
id=tenant.id, id=tenant.id,
slug=tenant.slug, slug=tenant.slug,
@@ -266,8 +669,8 @@ def _me_response(
scopes=frozenset(scopes), scopes=frozenset(scopes),
group_ids=frozenset(group.id for group in groups), group_ids=frozenset(group.id for group in groups),
role_ids=frozenset(role.id for role in tenant_roles + system_roles), role_ids=frozenset(role.id for role in tenant_roles + system_roles),
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)), function_assignment_ids=frozenset(resolved_function_assignment_ids),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)), delegation_ids=frozenset(resolved_delegation_ids),
auth_method=auth_method, auth_method=auth_method,
api_key_id=api_key_id, api_key_id=api_key_id,
session_id=session_id, session_id=session_id,
@@ -284,11 +687,20 @@ 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)
me_payload = _me_response(session, account=account, user=user, tenant=tenant, identity_directory=identity_directory) authorization_context = collect_user_authorization_context(
session,
user,
account=account,
include_system=True,
)
tenant_roles = authorization_context.tenant_roles
system_roles = authorization_context.system_roles
groups = authorization_context.groups
effective_scopes = authorization_context.scopes
maintenance_mode = saved_maintenance_mode(session) maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE): if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=maintenance_response_detail(maintenance_mode), detail=maintenance_response_detail(maintenance_mode),
@@ -312,19 +724,66 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
account=account, account=account,
user=user, user=user,
tenant=tenant, tenant=tenant,
effective_scopes=me_payload.scopes, effective_scopes=effective_scopes,
auth_method="session", auth_method="session",
session_id=created.model.id, session_id=created.model.id,
identity_directory=identity_directory, identity_directory=identity_directory,
tenant_roles=tenant_roles,
system_roles=system_roles,
groups=groups,
function_assignment_ids=authorization_context.function_assignment_ids,
delegation_ids=authorization_context.function_delegation_ids,
).model_dump(), ).model_dump(),
) )
@router.get("/session", response_model=AuthSessionResponse)
def auth_session(request: Request, session: Session = Depends(get_session)):
return _resolve_lightweight_session(request, session)
@router.get("/shell", response_model=AuthShellResponse)
def auth_shell(request: Request, session: Session = Depends(get_session)):
return _resolve_shell_auth(request, session)
@router.get("/profile", response_model=AuthProfileResponse)
def auth_profile(request: Request, session: Session = Depends(get_session)):
return _profile_response(session, _resolve_auth_context(request, session))
@router.get("/roles", response_model=AuthRolesResponse)
def auth_roles(request: Request, session: Session = Depends(get_session)):
context = _resolve_auth_context(request, session)
authorization_context = collect_user_authorization_context(
session,
context.user,
account=context.account,
include_system=context.auth_session is not None,
)
return AuthRolesResponse(
roles=_roles_info(authorization_context.tenant_roles)
+ _roles_info(authorization_context.system_roles, level="system")
)
@router.get("/groups", response_model=AuthGroupsResponse)
def auth_groups(request: Request, session: Session = Depends(get_session)):
return _groups_response(session, _resolve_auth_context(request, session))
@router.get("/me", response_model=MeResponse) @router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)): def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id) tenant = session.get(Tenant, principal.tenant_id)
if tenant is None: if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
tenant_roles: list[Role] | None = None
system_roles: list[Role] | None = None
groups: list[Group] | None = None
if principal.role_ids:
tenant_roles, system_roles = _roles_from_principal(session, principal, tenant_id=tenant.id)
if principal.group_ids:
groups = _groups_from_principal(session, principal, tenant_id=tenant.id)
return _me_response( return _me_response(
session, session,
account=principal.account, account=principal.account,
@@ -338,33 +797,42 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
include_system=principal.auth_session is not None, include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None, include_all_memberships=principal.auth_session is not None,
identity_id=principal.principal.identity_id, identity_id=principal.principal.identity_id,
tenant_roles=tenant_roles,
system_roles=system_roles,
groups=groups,
function_assignment_ids=tuple(principal.function_assignment_ids),
delegation_ids=tuple(principal.delegation_ids),
) )
@router.patch("/profile", response_model=MeResponse) @router.patch("/profile", response_model=AuthProfileResponse)
def update_profile( def update_profile(
payload: ProfileUpdateRequest, payload: ProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal), request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
if principal.auth_session is None: context = _resolve_auth_context(request, session)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile") _verify_profile_mutation_allowed(request, context)
if "display_name" in payload.model_fields_set: if "display_name" in payload.model_fields_set:
principal.account.display_name = payload.display_name.strip() if payload.display_name else None context.account.display_name = payload.display_name.strip() if payload.display_name else None
session.add(principal.account) session.add(context.account)
if "tenant_display_name" in payload.model_fields_set: if "tenant_display_name" in payload.model_fields_set:
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None context.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(principal.user) session.add(context.user)
next_settings = dict(principal.user.settings or {}) next_settings = dict(context.user.settings or {})
settings_changed = False settings_changed = False
if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set): if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
system_settings = get_system_settings(session) system_settings = get_system_settings(session)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) system_enabled = system_enabled_language_codes(
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale) system_settings.settings,
default_locale=system_settings.default_locale,
)
tenant_enabled = tenant_enabled_language_codes(
context.tenant.settings,
system_enabled,
default_locale=context.tenant.default_locale,
)
next_i18n = i18n_settings(next_settings) next_i18n = i18n_settings(next_settings)
if "preferred_language" in payload.model_fields_set: if "preferred_language" in payload.model_fields_set:
preferred = normalize_language_code(payload.preferred_language) preferred = normalize_language_code(payload.preferred_language)
@@ -394,35 +862,23 @@ def update_profile(
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump() next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
settings_changed = True settings_changed = True
if settings_changed: if settings_changed:
principal.user.settings = next_settings context.user.settings = next_settings
session.add(principal.user) session.add(context.user)
audit_from_principal( audit_event(
session, session,
principal, tenant_id=context.tenant.id,
user_id=context.user.id,
action="profile.updated", action="profile.updated",
object_type="account", object_type="account",
object_id=principal.account.id, object_id=context.account.id,
details={"fields": sorted(payload.model_fields_set)}, details={"fields": sorted(payload.model_fields_set)},
) )
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
session.commit() session.commit()
return _me_response( return _profile_response(session, context)
session,
account=principal.account,
user=principal.user,
tenant=tenant,
auth_method=principal.auth_method,
session_id=principal.session_id,
include_system=True,
include_all_memberships=True,
identity_id=principal.principal.identity_id,
)
@router.post("/switch-tenant", response_model=MeResponse) @router.post("/switch-tenant", response_model=AuthShellResponse)
def switch_tenant( def switch_tenant(
payload: SwitchTenantRequest, payload: SwitchTenantRequest,
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
@@ -441,14 +897,20 @@ def switch_tenant(
if membership is None: if membership is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
session.commit() session.commit()
return _me_response( authorization_context = collect_user_authorization_context(
session,
membership,
account=principal.account,
include_system=True,
)
return _shell_response(
session, session,
account=principal.account, account=principal.account,
user=membership, user=membership,
tenant=tenant, tenant=tenant,
scopes=authorization_context.scopes,
auth_method="session", auth_method="session",
session_id=principal.session_id, auth_session=principal.auth_session,
identity_id=principal.principal.identity_id,
) )

View File

@@ -41,15 +41,23 @@ from govoplan_access.backend.admin.service import (
update_system_role, update_system_role,
) )
from govoplan_access.backend.api.v1.admin_common import ( from govoplan_access.backend.api.v1.admin_common import (
_accounts_by_id,
_accounts_by_user_id,
_api_key_item, _api_key_item,
_group_member_ids_by_group_id,
_group_summary, _group_summary,
_groups_by_user_id,
_http_admin_error, _http_admin_error,
_require_permission, _require_permission,
_require_system_role_assignment, _require_system_role_assignment,
_resolve_tenant, _resolve_tenant,
_roles_by_group_id,
_roles_by_user_id,
_role_summary, _role_summary,
_set_system_memberships, _set_system_memberships,
_system_role_assignment_counts,
_system_account_item, _system_account_item,
_tenant_role_assignment_counts,
_user_item, _user_item,
) )
from govoplan_access.backend.api.v1.admin_schemas import ( from govoplan_access.backend.api.v1.admin_schemas import (
@@ -192,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,
) )
@@ -213,6 +220,45 @@ ACCESS_FUNCTION_DELEGATIONS_COLLECTION = "access.admin.function_delegations"
ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles" ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles"
ACCESS_SYSTEM_ACCOUNTS_COLLECTION = "access.admin.system_accounts" ACCESS_SYSTEM_ACCOUNTS_COLLECTION = "access.admin.system_accounts"
ACCESS_API_KEYS_COLLECTION = "access.admin.api_keys" ACCESS_API_KEYS_COLLECTION = "access.admin.api_keys"
ACCESS_FULL_CURSOR_PREFIX = "full:"
def _page_query(query, *, page: int, page_size: int):
total = query.order_by(None).count()
pages = max(1, (total + page_size - 1) // page_size)
items = query.offset((page - 1) * page_size).limit(page_size).all()
return items, {"total": total, "page": page, "page_size": page_size, "pages": pages}
def _encode_full_delta_cursor(scope: str, *, page: int, snapshot_sequence: int) -> str:
return f"{ACCESS_FULL_CURSOR_PREFIX}{scope}:{int(page)}:{int(snapshot_sequence)}"
def _decode_full_delta_cursor(value: str | None, *, scope: str) -> tuple[int, int] | None:
if not value or not value.startswith(ACCESS_FULL_CURSOR_PREFIX):
return None
parts = value.split(":", 3)
if len(parts) != 4 or parts[1] != scope:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor")
try:
page = int(parts[2])
snapshot_sequence = int(parts[3])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor") from exc
if page < 1 or snapshot_sequence < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor")
return page, snapshot_sequence
def _full_delta_page(query, *, page: int, page_size: int, scope: str, snapshot_sequence: int):
items, pagination = _page_query(query, page=page, page_size=page_size)
has_more = page < pagination["pages"]
watermark = (
_encode_full_delta_cursor(scope, page=page + 1, snapshot_sequence=snapshot_sequence)
if has_more
else encode_sequence_watermark(snapshot_sequence)
)
return items, pagination, watermark, has_more
def _access_delta_watermark(session: Session, tenant_id: str | None, collections: tuple[str, ...]) -> str: def _access_delta_watermark(session: Session, tenant_id: str | None, collections: tuple[str, ...]) -> str:
@@ -318,14 +364,35 @@ def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}")
def _identity_item(session: Session, identity: Identity) -> IdentityAdminItem: def _identity_account_links_by_identity(session: Session, identities: list[Identity]) -> dict[str, list[tuple[IdentityAccountLink, Account]]]:
identity_ids = [identity.id for identity in identities]
if not identity_ids:
return {}
grouped: dict[str, list[tuple[IdentityAccountLink, Account]]] = {}
rows = ( rows = (
session.query(IdentityAccountLink, Account) session.query(IdentityAccountLink, Account)
.join(Account, Account.id == IdentityAccountLink.account_id) .join(Account, Account.id == IdentityAccountLink.account_id)
.filter(IdentityAccountLink.identity_id == identity.id) .filter(IdentityAccountLink.identity_id.in_(identity_ids))
.order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc()) .order_by(IdentityAccountLink.identity_id.asc(), IdentityAccountLink.is_primary.desc(), Account.email.asc())
.all() .all()
) )
for link, account in rows:
grouped.setdefault(link.identity_id, []).append((link, account))
return grouped
def _identity_item(session: Session, identity: Identity, *, account_links_by_identity: dict[str, list[tuple[IdentityAccountLink, Account]]] | None = None) -> IdentityAdminItem:
rows = (
account_links_by_identity.get(identity.id, [])
if account_links_by_identity is not None
else (
session.query(IdentityAccountLink, Account)
.join(Account, Account.id == IdentityAccountLink.account_id)
.filter(IdentityAccountLink.identity_id == identity.id)
.order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc())
.all()
)
)
return IdentityAdminItem( return IdentityAdminItem(
id=identity.id, id=identity.id,
display_name=identity.display_name, display_name=identity.display_name,
@@ -1073,12 +1140,16 @@ def approve_configuration_change_request_endpoint(
@router.get("/identities", response_model=IdentityListResponse) @router.get("/identities", response_model=IdentityListResponse)
def list_identities( def list_identities(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "access:account:read")), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "access:account:read")),
): ):
del principal del principal
identities = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc()).all() query = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc())
return IdentityListResponse(identities=[_identity_item(session, item) for item in identities]) identities, pagination = _page_query(query, page=page, page_size=page_size)
account_links_by_identity = _identity_account_links_by_identity(session, identities)
return IdentityListResponse(identities=[_identity_item(session, item, account_links_by_identity=account_links_by_identity) for item in identities], **pagination)
@router.post("/identities", response_model=IdentityAdminItem, status_code=status.HTTP_201_CREATED) @router.post("/identities", response_model=IdentityAdminItem, status_code=status.HTTP_201_CREATED)
@@ -1952,24 +2023,67 @@ def revoke_function_delegation(
return None return None
def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse: def _user_items_for_response(session: Session, tenant: Tenant, users: list[User]) -> list[UserAdminItem]:
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() user_ids = [user.id for user in users]
accounts_by_id = _accounts_by_id(session, [user.account_id for user in users])
groups_by_user = _groups_by_user_id(session, tenant_id=tenant.id, user_ids=user_ids)
roles_by_user = _roles_by_user_id(session, tenant_id=tenant.id, user_ids=user_ids)
group_ids = sorted({group.id for groups in groups_by_user.values() for group in groups})
group_member_ids_by_group = _group_member_ids_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids)
group_roles_by_group = _roles_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids)
role_ids = sorted(
{
role.id
for roles in roles_by_user.values()
for role in roles
}
| {
role.id
for roles in group_roles_by_group.values()
for role in roles
}
)
tenant_role_counts = _tenant_role_assignment_counts(session, role_ids)
owner_ids = tenant_owner_user_ids(session, tenant.id) owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory() organization_directory = _optional_organization_directory()
return [
_user_item_for_response(
session,
user,
owner_ids=owner_ids,
idm_directory=idm_directory,
organization_directory=organization_directory,
accounts_by_id=accounts_by_id,
groups_by_user=groups_by_user,
roles_by_user=roles_by_user,
group_member_ids_by_group=group_member_ids_by_group,
group_roles_by_group=group_roles_by_group,
tenant_role_assignment_counts=tenant_role_counts,
)
for user in users
]
def _full_users_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> UserListDeltaResponse:
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_USERS_COLLECTION,))
page = cursor[0] if cursor is not None else 1
query = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc(), User.id.asc())
users, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="users", snapshot_sequence=snapshot_sequence)
return UserListDeltaResponse( return UserListDeltaResponse(
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users], users=_user_items_for_response(session, tenant, users),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> UserListDeltaResponse: def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> UserListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_users_delta_response(session, tenant) return _full_users_delta_response(session, tenant, limit=limit)
changed_ids = _changed_ids(entries, "access_user") changed_ids = _changed_ids(entries, "access_user")
visible = { visible = {
user.id: user user.id: user
@@ -1978,16 +2092,13 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
if changed_ids else [] if changed_ids else []
) )
} }
owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
deleted = [ deleted = [
_delta_deleted_item(entry) _delta_deleted_item(entry)
for entry in entries for entry in entries
if entry.resource_type == "access_user" and entry.resource_id not in visible if entry.resource_type == "access_user" and entry.resource_id not in visible
] ]
return UserListDeltaResponse( return UserListDeltaResponse(
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in visible.values()], users=_user_items_for_response(session, tenant, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2002,6 +2113,12 @@ def _user_item_for_response(
owner_ids: set[str] | None = None, owner_ids: set[str] | None = None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
accounts_by_id: dict[str, Account] | None = None,
groups_by_user: dict[str, list[Group]] | None = None,
roles_by_user: dict[str, list[Role]] | None = None,
group_member_ids_by_group: dict[str, list[str]] | None = None,
group_roles_by_group: dict[str, list[Role]] | None = None,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
) -> UserAdminItem: ) -> UserAdminItem:
idm_assignments = _idm_assignments_for_user(idm_directory, user) idm_assignments = _idm_assignments_for_user(idm_directory, user)
return _user_item( return _user_item(
@@ -2010,6 +2127,12 @@ def _user_item_for_response(
owner_ids=owner_ids, owner_ids=owner_ids,
idm_assignments=idm_assignments, idm_assignments=idm_assignments,
organization_directory=organization_directory, organization_directory=organization_directory,
accounts_by_id=accounts_by_id,
groups_by_user=groups_by_user,
roles_by_user=roles_by_user,
group_member_ids_by_group=group_member_ids_by_group,
group_roles_by_group=group_roles_by_group,
tenant_role_assignment_counts=tenant_role_assignment_counts,
) )
@@ -2022,23 +2145,27 @@ def list_users_delta(
principal: ApiPrincipal = Depends(require_scope("admin:users:read")), principal: ApiPrincipal = Depends(require_scope("admin:users:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
if since is None: full_cursor = _decode_full_delta_cursor(since, scope="users")
return _full_users_delta_response(session, tenant) if since is None or full_cursor is not None:
return _full_users_delta_response(session, tenant, cursor=full_cursor, limit=limit)
return _users_delta_response(session, tenant, since=since, limit=limit) return _users_delta_response(session, tenant, since=since, limit=limit)
@router.get("/users", response_model=UserListResponse) @router.get("/users", response_model=UserListResponse)
def list_users( def list_users(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:users:read")), principal: ApiPrincipal = Depends(require_scope("admin:users:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() query = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc())
owner_ids = tenant_owner_user_ids(session, tenant.id) users, pagination = _page_query(query, page=page, page_size=page_size)
idm_directory = _optional_idm_directory() return UserListResponse(
organization_directory = _optional_organization_directory() users=_user_items_for_response(session, tenant, users),
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users]) **pagination,
)
@router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse) @router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
@@ -2198,14 +2325,7 @@ def create_user(
) )
@router.patch("/users/{user_id}", response_model=UserAdminItem) def _require_user_update_permissions(principal: ApiPrincipal, payload: UserUpdateRequest) -> None:
def update_user(
user_id: str,
payload: UserUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if "display_name" in payload.model_fields_set: if "display_name" in payload.model_fields_set:
_require_permission(principal, "admin:users:update") _require_permission(principal, "admin:users:update")
if payload.is_active is not None: if payload.is_active is not None:
@@ -2214,10 +2334,16 @@ def update_user(
_require_permission(principal, "admin:groups:manage_members") _require_permission(principal, "admin:groups:manage_members")
if payload.role_ids is not None: if payload.role_ids is not None:
_require_permission(principal, "admin:roles:assign") _require_permission(principal, "admin:roles:assign")
tenant = _resolve_tenant(session, principal, tenant_id)
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
if user is None: def _apply_user_update(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") session: Session,
*,
tenant: Tenant,
user: User,
payload: UserUpdateRequest,
principal: ApiPrincipal,
) -> tuple[set[str], set[str]]:
previous_group_ids = _current_user_group_ids(session, user) if payload.group_ids is not None else set() previous_group_ids = _current_user_group_ids(session, user) if payload.group_ids is not None else set()
previous_role_ids = _current_user_role_ids(session, user) if payload.role_ids is not None else set() previous_role_ids = _current_user_role_ids(session, user) if payload.role_ids is not None else set()
try: try:
@@ -2234,6 +2360,19 @@ def update_user(
assert_tenant_owner_exists(session, tenant.id) assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc: except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc raise _http_admin_error(exc) from exc
return previous_group_ids, previous_role_ids
def _record_user_update_changes(
session: Session,
*,
tenant: Tenant,
user: User,
payload: UserUpdateRequest,
principal: ApiPrincipal,
previous_group_ids: set[str],
previous_role_ids: set[str],
) -> None:
_record_access_change( _record_access_change(
session, session,
collection=ACCESS_USERS_COLLECTION, collection=ACCESS_USERS_COLLECTION,
@@ -2263,6 +2402,37 @@ def update_user(
tenant_id=tenant.id, tenant_id=tenant.id,
principal=principal, principal=principal,
) )
@router.patch("/users/{user_id}", response_model=UserAdminItem)
def update_user(
user_id: str,
payload: UserUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
_require_user_update_permissions(principal, payload)
tenant = _resolve_tenant(session, principal, tenant_id)
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
previous_group_ids, previous_role_ids = _apply_user_update(
session,
tenant=tenant,
user=user,
payload=payload,
principal=principal,
)
_record_user_update_changes(
session,
tenant=tenant,
user=user,
payload=payload,
principal=principal,
previous_group_ids=previous_group_ids,
previous_role_ids=previous_role_ids,
)
audit_event( audit_event(
session, session,
tenant_id=tenant.id, tenant_id=tenant.id,
@@ -2281,21 +2451,43 @@ def update_user(
) )
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse: def _group_summaries_for_response(session: Session, tenant: Tenant, groups: list[Group]) -> list[GroupSummary]:
groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() group_ids = [group.id for group in groups]
member_ids_by_group = _group_member_ids_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids)
roles_by_group = _roles_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids)
role_ids = sorted({role.id for roles in roles_by_group.values() for role in roles})
tenant_role_counts = _tenant_role_assignment_counts(session, role_ids)
return [
_group_summary(
session,
group,
member_ids_by_group=member_ids_by_group,
roles_by_group=roles_by_group,
tenant_role_assignment_counts=tenant_role_counts,
)
for group in groups
]
def _full_groups_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> GroupListDeltaResponse:
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_GROUPS_COLLECTION,))
page = cursor[0] if cursor is not None else 1
query = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc(), Group.id.asc())
groups, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="groups", snapshot_sequence=snapshot_sequence)
return GroupListDeltaResponse( return GroupListDeltaResponse(
groups=[_group_summary(session, group) for group in groups], groups=_group_summaries_for_response(session, tenant, groups),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_GROUPS_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> GroupListDeltaResponse: def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> GroupListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_groups_delta_response(session, tenant) return _full_groups_delta_response(session, tenant, limit=limit)
changed_ids = _changed_ids(entries, "access_group") changed_ids = _changed_ids(entries, "access_group")
visible = { visible = {
group.id: group group.id: group
@@ -2310,7 +2502,7 @@ def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limi
if entry.resource_type == "access_group" and entry.resource_id not in visible if entry.resource_type == "access_group" and entry.resource_id not in visible
] ]
return GroupListDeltaResponse( return GroupListDeltaResponse(
groups=[_group_summary(session, group) for group in visible.values()], groups=_group_summaries_for_response(session, tenant, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2327,20 +2519,27 @@ def list_groups_delta(
principal: ApiPrincipal = Depends(require_scope("admin:groups:read")), principal: ApiPrincipal = Depends(require_scope("admin:groups:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
if since is None: full_cursor = _decode_full_delta_cursor(since, scope="groups")
return _full_groups_delta_response(session, tenant) if since is None or full_cursor is not None:
return _full_groups_delta_response(session, tenant, cursor=full_cursor, limit=limit)
return _groups_delta_response(session, tenant, since=since, limit=limit) return _groups_delta_response(session, tenant, since=since, limit=limit)
@router.get("/groups", response_model=GroupListResponse) @router.get("/groups", response_model=GroupListResponse)
def list_groups( def list_groups(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:groups:read")), principal: ApiPrincipal = Depends(require_scope("admin:groups:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() query = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc())
return GroupListResponse(groups=[_group_summary(session, group) for group in groups]) groups, pagination = _page_query(query, page=page, page_size=page_size)
return GroupListResponse(
groups=_group_summaries_for_response(session, tenant, groups),
**pagination,
)
@router.post("/groups", response_model=GroupSummary, status_code=status.HTTP_201_CREATED) @router.post("/groups", response_model=GroupSummary, status_code=status.HTTP_201_CREATED)
@@ -2419,24 +2618,23 @@ def create_group(
return _group_summary(session, group) return _group_summary(session, group)
@router.patch("/groups/{group_id}", response_model=GroupSummary) def _require_group_update_permissions(principal: ApiPrincipal, payload: GroupUpdateRequest) -> None:
def update_group(
group_id: str,
payload: GroupUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if any(field in payload.model_fields_set for field in ("name", "description", "is_active")): if any(field in payload.model_fields_set for field in ("name", "description", "is_active")):
_require_permission(principal, "admin:groups:write") _require_permission(principal, "admin:groups:write")
if payload.member_ids is not None: if payload.member_ids is not None:
_require_permission(principal, "admin:groups:manage_members") _require_permission(principal, "admin:groups:manage_members")
if payload.role_ids is not None: if payload.role_ids is not None:
_require_permission(principal, "admin:roles:assign") _require_permission(principal, "admin:roles:assign")
tenant = _resolve_tenant(session, principal, tenant_id)
group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none()
if group is None: def _apply_group_update(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") session: Session,
*,
tenant: Tenant,
group: Group,
payload: GroupUpdateRequest,
principal: ApiPrincipal,
) -> tuple[set[str], set[str]]:
previous_member_ids = _current_group_member_ids(session, group) if payload.member_ids is not None else set() previous_member_ids = _current_group_member_ids(session, group) if payload.member_ids is not None else set()
previous_role_ids = _current_group_role_ids(session, group) if payload.role_ids is not None else set() previous_role_ids = _current_group_role_ids(session, group) if payload.role_ids is not None else set()
try: try:
@@ -2456,6 +2654,19 @@ def update_group(
assert_tenant_owner_exists(session, tenant.id) assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc: except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc raise _http_admin_error(exc) from exc
return previous_member_ids, previous_role_ids
def _record_group_update_changes(
session: Session,
*,
tenant: Tenant,
group: Group,
payload: GroupUpdateRequest,
principal: ApiPrincipal,
previous_member_ids: set[str],
previous_role_ids: set[str],
) -> None:
_record_access_change( _record_access_change(
session, session,
collection=ACCESS_GROUPS_COLLECTION, collection=ACCESS_GROUPS_COLLECTION,
@@ -2485,6 +2696,37 @@ def update_group(
tenant_id=tenant.id, tenant_id=tenant.id,
principal=principal, principal=principal,
) )
@router.patch("/groups/{group_id}", response_model=GroupSummary)
def update_group(
group_id: str,
payload: GroupUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
_require_group_update_permissions(principal, payload)
tenant = _resolve_tenant(session, principal, tenant_id)
group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none()
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
previous_member_ids, previous_role_ids = _apply_group_update(
session,
tenant=tenant,
group=group,
payload=payload,
principal=principal,
)
_record_group_update_changes(
session,
tenant=tenant,
group=group,
payload=payload,
principal=principal,
previous_member_ids=previous_member_ids,
previous_role_ids=previous_role_ids,
)
audit_event( audit_event(
session, session,
tenant_id=tenant.id, tenant_id=tenant.id,
@@ -2498,23 +2740,32 @@ def update_group(
return _group_summary(session, group) return _group_summary(session, group)
def _full_roles_delta_response(session: Session, tenant: Tenant) -> RoleListDeltaResponse: def _tenant_role_summaries_for_response(session: Session, roles: list[Role]) -> list[RoleSummary]:
tenant_role_counts = _tenant_role_assignment_counts(session, [role.id for role in roles])
return [_role_summary(session, role, tenant_role_assignment_counts=tenant_role_counts) for role in roles]
def _full_roles_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
ensure_default_roles(session, tenant) ensure_default_roles(session, tenant)
session.commit() session.commit()
roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_ROLES_COLLECTION,))
page = cursor[0] if cursor is not None else 1
query = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc(), Role.id.asc())
roles, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="roles", snapshot_sequence=snapshot_sequence)
return RoleListDeltaResponse( return RoleListDeltaResponse(
roles=[_role_summary(session, role) for role in roles], roles=_tenant_role_summaries_for_response(session, roles),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_ROLES_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> RoleListDeltaResponse: def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> RoleListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_roles_delta_response(session, tenant) return _full_roles_delta_response(session, tenant, limit=limit)
changed_ids = _changed_ids(entries, "access_role") changed_ids = _changed_ids(entries, "access_role")
visible = { visible = {
role.id: role role.id: role
@@ -2529,7 +2780,7 @@ def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit
if entry.resource_type == "access_role" and entry.resource_id not in visible if entry.resource_type == "access_role" and entry.resource_id not in visible
] ]
return RoleListDeltaResponse( return RoleListDeltaResponse(
roles=[_role_summary(session, role) for role in visible.values()], roles=_tenant_role_summaries_for_response(session, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2546,22 +2797,29 @@ def list_roles_delta(
principal: ApiPrincipal = Depends(require_scope("admin:roles:read")), principal: ApiPrincipal = Depends(require_scope("admin:roles:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
if since is None: full_cursor = _decode_full_delta_cursor(since, scope="roles")
return _full_roles_delta_response(session, tenant) if since is None or full_cursor is not None:
return _full_roles_delta_response(session, tenant, cursor=full_cursor, limit=limit)
return _roles_delta_response(session, tenant, since=since, limit=limit) return _roles_delta_response(session, tenant, since=since, limit=limit)
@router.get("/roles", response_model=RoleListResponse) @router.get("/roles", response_model=RoleListResponse)
def list_roles( def list_roles(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:roles:read")), principal: ApiPrincipal = Depends(require_scope("admin:roles:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
ensure_default_roles(session, tenant) ensure_default_roles(session, tenant)
session.commit() session.commit()
roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() query = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc())
return RoleListResponse(roles=[_role_summary(session, role) for role in roles]) roles, pagination = _page_query(query, page=page, page_size=page_size)
return RoleListResponse(
roles=_tenant_role_summaries_for_response(session, roles),
**pagination,
)
@router.post("/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED) @router.post("/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED)
@@ -2718,23 +2976,32 @@ def delete_role(
return None return None
def _full_system_roles_delta_response(session: Session) -> RoleListDeltaResponse: def _system_role_summaries_for_response(session: Session, roles: list[Role]) -> list[RoleSummary]:
system_role_counts = _system_role_assignment_counts(session, [role.id for role in roles])
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
ensure_default_roles(session, None) ensure_default_roles(session, None)
session.commit() session.commit()
roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,))
page = cursor[0] if cursor is not None else 1
query = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc(), Role.id.asc())
roles, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-roles", snapshot_sequence=snapshot_sequence)
return RoleListDeltaResponse( return RoleListDeltaResponse(
roles=[_role_summary(session, role) for role in roles], roles=_system_role_summaries_for_response(session, roles),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, None, (ACCESS_SYSTEM_ROLES_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _system_roles_delta_response(session: Session, *, since: str, limit: int) -> RoleListDeltaResponse: def _system_roles_delta_response(session: Session, *, since: str, limit: int) -> RoleListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_system_roles_delta_response(session) return _full_system_roles_delta_response(session, limit=limit)
changed_ids = _changed_ids(entries, "access_system_role") changed_ids = _changed_ids(entries, "access_system_role")
visible = { visible = {
role.id: role role.id: role
@@ -2749,7 +3016,7 @@ def _system_roles_delta_response(session: Session, *, since: str, limit: int) ->
if entry.resource_type == "access_system_role" and entry.resource_id not in visible if entry.resource_type == "access_system_role" and entry.resource_id not in visible
] ]
return RoleListDeltaResponse( return RoleListDeltaResponse(
roles=[_role_summary(session, role) for role in visible.values()], roles=_system_role_summaries_for_response(session, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2765,20 +3032,27 @@ def list_system_roles_delta(
principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")),
): ):
del principal del principal
if since is None: full_cursor = _decode_full_delta_cursor(since, scope="system-roles")
return _full_system_roles_delta_response(session) if since is None or full_cursor is not None:
return _full_system_roles_delta_response(session, cursor=full_cursor, limit=limit)
return _system_roles_delta_response(session, since=since, limit=limit) return _system_roles_delta_response(session, since=since, limit=limit)
@router.get("/system/roles", response_model=RoleListResponse) @router.get("/system/roles", response_model=RoleListResponse)
def list_system_roles( def list_system_roles(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")),
): ):
ensure_default_roles(session, None) ensure_default_roles(session, None)
session.commit() session.commit()
roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() query = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc())
return RoleListResponse(roles=[_role_summary(session, role) for role in roles]) roles, pagination = _page_query(query, page=page, page_size=page_size)
return RoleListResponse(
roles=_system_role_summaries_for_response(session, roles),
**pagination,
)
@router.post("/system/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED) @router.post("/system/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED)
@@ -2901,25 +3175,29 @@ def delete_system_role_endpoint(
_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_SYSTEM_ROLES_COLLECTION) _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_SYSTEM_ROLES_COLLECTION)
def _full_system_accounts_delta_response(session: Session) -> SystemAccountListDeltaResponse: def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
ensure_default_roles(session, None) ensure_default_roles(session, None)
session.commit() session.commit()
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
accounts = session.query(Account).order_by(Account.email.asc()).all() snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS)
page = cursor[0] if cursor is not None else 1
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc())
accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
return SystemAccountListDeltaResponse( return SystemAccountListDeltaResponse(
accounts=[_system_account_item(session, account) for account in accounts], accounts=[_system_account_item(session, account) for account in accounts],
roles=[_role_summary(session, role) for role in system_roles], roles=_system_role_summaries_for_response(session, system_roles),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, None, _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _system_accounts_delta_response(session: Session, *, since: str, limit: int) -> SystemAccountListDeltaResponse: def _system_accounts_delta_response(session: Session, *, since: str, limit: int) -> SystemAccountListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, since=since, limit=limit)
if entries is None: if entries is None:
return _full_system_accounts_delta_response(session) return _full_system_accounts_delta_response(session, limit=limit)
account_ids = _changed_ids(entries, "access_system_account") account_ids = _changed_ids(entries, "access_system_account")
role_ids = _changed_ids(entries, "access_system_role") role_ids = _changed_ids(entries, "access_system_role")
visible_accounts = { visible_accounts = {
@@ -2946,7 +3224,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int)
] ]
return SystemAccountListDeltaResponse( return SystemAccountListDeltaResponse(
accounts=[_system_account_item(session, account) for account in visible_accounts.values()], accounts=[_system_account_item(session, account) for account in visible_accounts.values()],
roles=[_role_summary(session, role) for role in visible_roles.values()], roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2962,42 +3240,58 @@ def list_system_accounts_delta(
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")),
): ):
del principal del principal
if since is None: full_cursor = _decode_full_delta_cursor(since, scope="system-accounts")
return _full_system_accounts_delta_response(session) if since is None or full_cursor is not None:
return _full_system_accounts_delta_response(session, cursor=full_cursor, limit=limit)
return _system_accounts_delta_response(session, since=since, limit=limit) return _system_accounts_delta_response(session, since=since, limit=limit)
@router.get("/system/accounts", response_model=SystemAccountListResponse) @router.get("/system/accounts", response_model=SystemAccountListResponse)
def list_system_accounts( def list_system_accounts(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")),
): ):
ensure_default_roles(session, None) ensure_default_roles(session, None)
session.commit() session.commit()
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
accounts = session.query(Account).order_by(Account.email.asc()).all() query = session.query(Account).order_by(Account.email.asc())
accounts, pagination = _page_query(query, page=page, page_size=page_size)
return SystemAccountListResponse( return SystemAccountListResponse(
accounts=[_system_account_item(session, account) for account in accounts], accounts=[_system_account_item(session, account) for account in accounts],
roles=[_role_summary(session, role) for role in system_roles], roles=_system_role_summaries_for_response(session, system_roles),
**pagination,
) )
@router.patch("/system/accounts/{account_id}", response_model=SystemAccountItem) def _require_system_account_update_permissions(principal: ApiPrincipal, payload: SystemAccountUpdateRequest) -> None:
def update_system_account(
account_id: str,
payload: SystemAccountUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if "display_name" in payload.model_fields_set and not has_scope(principal, "system:accounts:update"): if "display_name" in payload.model_fields_set and not has_scope(principal, "system:accounts:update"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:update") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:update")
if payload.is_active is not None and not has_scope(principal, "system:accounts:suspend"): if payload.is_active is not None and not has_scope(principal, "system:accounts:suspend"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:suspend") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:suspend")
if payload.role_ids is not None: if payload.role_ids is not None:
_require_system_role_assignment(principal) _require_system_role_assignment(principal)
account = session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") def _active_tenant_ids_for_account(session: Session, account: Account) -> list[str]:
return [
row[0]
for row in session.query(User.tenant_id)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id, User.is_active.is_(True), Tenant.is_active.is_(True))
.distinct()
.all()
]
def _apply_system_account_update(
session: Session,
*,
account: Account,
payload: SystemAccountUpdateRequest,
principal: ApiPrincipal,
) -> set[str]:
previous_role_ids = _current_system_role_ids(session, account) if payload.role_ids is not None else set() previous_role_ids = _current_system_role_ids(session, account) if payload.role_ids is not None else set()
if "display_name" in payload.model_fields_set: if "display_name" in payload.model_fields_set:
account.display_name = payload.display_name.strip() if payload.display_name else None account.display_name = payload.display_name.strip() if payload.display_name else None
@@ -3013,18 +3307,21 @@ def update_system_account(
session.flush() session.flush()
assert_system_owner_exists(session) assert_system_owner_exists(session)
if not account.is_active: if not account.is_active:
tenant_ids = [ for tenant_id in _active_tenant_ids_for_account(session, account):
row[0]
for row in session.query(User.tenant_id)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id, User.is_active.is_(True), Tenant.is_active.is_(True))
.distinct()
.all()
]
for tenant_id in tenant_ids:
assert_tenant_owner_exists(session, tenant_id) assert_tenant_owner_exists(session, tenant_id)
except (AdminConflictError, AdminValidationError) as exc: except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc raise _http_admin_error(exc) from exc
return previous_role_ids
def _record_system_account_update_changes(
session: Session,
*,
account: Account,
payload: SystemAccountUpdateRequest,
principal: ApiPrincipal,
previous_role_ids: set[str],
) -> None:
_record_access_change( _record_access_change(
session, session,
collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION,
@@ -3044,6 +3341,27 @@ def update_system_account(
tenant_id=None, tenant_id=None,
principal=principal, principal=principal,
) )
@router.patch("/system/accounts/{account_id}", response_model=SystemAccountItem)
def update_system_account(
account_id: str,
payload: SystemAccountUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
_require_system_account_update_permissions(principal, payload)
account = session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
previous_role_ids = _apply_system_account_update(session, account=account, payload=payload, principal=principal)
_record_system_account_update_changes(
session,
account=account,
payload=payload,
principal=principal,
previous_role_ids=previous_role_ids,
)
audit_from_principal( audit_from_principal(
session, session,
principal, principal,
@@ -3266,24 +3584,38 @@ def update_system_account_memberships(
return _system_account_item(session, account) return _system_account_item(session, account)
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool) -> ApiKeyListDeltaResponse: def _api_key_items_for_response(session: Session, keys: list[ApiKey]) -> list[ApiKeyAdminItem]:
accounts_by_user_id = _accounts_by_user_id(session, [item.user_id for item in keys])
return [_api_key_item(session, item, accounts_by_user_id=accounts_by_user_id) for item in keys]
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse:
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
if not include_revoked: if not include_revoked:
query = query.filter(ApiKey.revoked_at.is_(None)) query = query.filter(ApiKey.revoked_at.is_(None))
keys = query.order_by(ApiKey.created_at.desc()).all() snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,))
page = cursor[0] if cursor is not None else 1
keys, pagination, watermark, has_more = _full_delta_page(
query.order_by(ApiKey.created_at.desc(), ApiKey.id.asc()),
page=page,
page_size=limit,
scope="api-keys",
snapshot_sequence=snapshot_sequence,
)
return ApiKeyListDeltaResponse( return ApiKeyListDeltaResponse(
api_keys=[_api_key_item(session, item) for item in keys], api_keys=_api_key_items_for_response(session, keys),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_API_KEYS_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, since: str, limit: int) -> ApiKeyListDeltaResponse: def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, since: str, limit: int) -> ApiKeyListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
changed_ids = _changed_ids(entries, "access_api_key") changed_ids = _changed_ids(entries, "access_api_key")
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
if not include_revoked: if not include_revoked:
@@ -3301,7 +3633,7 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke
if entry.resource_type == "access_api_key" and entry.resource_id not in visible if entry.resource_type == "access_api_key" and entry.resource_id not in visible
] ]
return ApiKeyListDeltaResponse( return ApiKeyListDeltaResponse(
api_keys=[_api_key_item(session, item) for item in visible.values()], api_keys=_api_key_items_for_response(session, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -3319,8 +3651,9 @@ def list_api_keys_delta(
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
if since is None: full_cursor = _decode_full_delta_cursor(since, scope="api-keys")
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) if since is None or full_cursor is not None:
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, cursor=full_cursor, limit=limit)
return _api_keys_delta_response(session, tenant, include_revoked=include_revoked, since=since, limit=limit) return _api_keys_delta_response(session, tenant, include_revoked=include_revoked, since=since, limit=limit)
@@ -3328,6 +3661,8 @@ def list_api_keys_delta(
def list_api_keys( def list_api_keys(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
include_revoked: bool = Query(default=False), include_revoked: bool = Query(default=False),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
): ):
@@ -3335,8 +3670,8 @@ def list_api_keys(
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
if not include_revoked: if not include_revoked:
query = query.filter(ApiKey.revoked_at.is_(None)) query = query.filter(ApiKey.revoked_at.is_(None))
keys = query.order_by(ApiKey.created_at.desc()).all() keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size)
return ApiKeyListResponse(api_keys=[_api_key_item(session, item) for item in keys]) return ApiKeyListResponse(api_keys=_api_key_items_for_response(session, keys), **pagination)
@router.post("/api-keys", response_model=AdminApiKeyCreateResponse, status_code=status.HTTP_201_CREATED) @router.post("/api-keys", response_model=AdminApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,20 +23,29 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_core.db.session import get_database, get_session from govoplan_core.db.session import get_database, get_session
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
from govoplan_access.backend.semantic import collect_external_function_roles, collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
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.sessions import ( from govoplan_access.backend.security.sessions import (
authenticate_session_token, authenticate_session_token,
collect_user_groups, collect_user_authorization_context,
collect_user_roles, UserAuthorizationContext,
collect_user_scopes,
verify_auth_session_csrf, verify_auth_session_csrf,
) )
from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_access.backend.permissions.catalog import expand_scopes, intersect_api_key_scopes from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
from govoplan_core.settings import settings from govoplan_core.settings import settings
@dataclass(slots=True)
class ResolvedPrincipalContext:
principal: PrincipalRef
account: Account
user: User
tenant: Tenant
api_key: ApiKey | None = None
auth_session: AuthSession | None = None
def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]: def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
if x_api_key: if x_api_key:
return x_api_key.strip(), "api_key" return x_api_key.strip(), "api_key"
@@ -50,10 +61,6 @@ def _requires_csrf(request: Request) -> bool:
return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"} return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"}
def _principal_group_ids(session: Session, user: User) -> frozenset[str]:
return frozenset(group.id for group in collect_user_groups(session, user))
def _build_principal_ref( def _build_principal_ref(
session: Session, session: Session,
*, *,
@@ -67,22 +74,32 @@ def _build_principal_ref(
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
identity_directory: IdentityDirectory | None = None, identity_directory: IdentityDirectory | None = None,
extra_roles: tuple[Role, ...] = (), extra_roles: tuple[Role, ...] = (),
authorization_context: UserAuthorizationContext | None = None,
include_system_roles: bool = False,
) -> PrincipalRef: ) -> PrincipalRef:
function_assignment_ids = list(collect_function_assignment_ids(session, user)) if authorization_context is None:
authorization_context = collect_user_authorization_context(
session,
user,
account=account,
include_system=include_system_roles,
extra_roles=extra_roles,
)
function_assignment_ids = list(authorization_context.function_assignment_ids)
function_assignment_ids.extend(item.id for item in idm_assignments) function_assignment_ids.extend(item.id for item in idm_assignments)
roles = collect_user_roles(session, user) role_ids = [role.id for role in authorization_context.tenant_roles]
role_ids = [role.id for role in roles] if include_system_roles:
role_ids.extend(role.id for role in extra_roles) role_ids.extend(role.id for role in authorization_context.system_roles)
return PrincipalRef( return PrincipalRef(
account_id=account.id, account_id=account.id,
membership_id=user.id, membership_id=user.id,
tenant_id=tenant_id, tenant_id=tenant_id,
identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory), identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
scopes=frozenset(scopes), scopes=frozenset(scopes),
group_ids=_principal_group_ids(session, user), group_ids=frozenset(group.id for group in authorization_context.groups),
role_ids=frozenset(sorted(dict.fromkeys(role_ids))), role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))), function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)), delegation_ids=frozenset(authorization_context.function_delegation_ids),
auth_method=auth_method, # type: ignore[arg-type] auth_method=auth_method, # type: ignore[arg-type]
api_key_id=api_key.id if api_key else None, api_key_id=api_key.id if api_key else None,
session_id=auth_session.id if auth_session else None, session_id=auth_session.id if auth_session else None,
@@ -124,6 +141,21 @@ def _api_principal_from_ref(
) )
def _api_principal_from_context(
context: ResolvedPrincipalContext,
*,
permission_evaluator: PermissionEvaluator | None = None,
) -> ApiPrincipal:
return ApiPrincipal(
principal=context.principal,
account=context.account,
user=context.user,
api_key=context.api_key,
auth_session=context.auth_session,
permission_evaluator=permission_evaluator,
)
def _resolve_legacy_principal_ref( def _resolve_legacy_principal_ref(
request: Request, request: Request,
session: Session, session: Session,
@@ -134,76 +166,232 @@ def _resolve_legacy_principal_ref(
identity_directory: IdentityDirectory | None = None, identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
) -> PrincipalRef: ) -> PrincipalRef:
return _resolve_legacy_principal_context(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
).principal
def _resolve_legacy_principal_context(
request: Request,
session: Session,
*,
authorization: str | None,
x_api_key: str | None,
idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> ResolvedPrincipalContext:
token, source = _extract_token(request, authorization, x_api_key) token, source = _extract_token(request, authorization, x_api_key)
if not token: if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
if source != "cookie":
context = _resolve_api_key_principal_context(
session,
token=token,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
if context is not None:
return context
context = _resolve_session_principal_context(
request,
session,
token=token,
source=source,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
if context is not None:
return context
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def _resolve_api_key_principal_ref(
session: Session,
*,
token: str,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> PrincipalRef | None:
context = _resolve_api_key_principal_context(
session,
token=token,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
return context.principal if context is not None else None
def _resolve_api_key_principal_context(
session: Session,
*,
token: str,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext | None:
# API keys remain supported for CLI/automation. Their permissions are the # API keys remain supported for CLI/automation. Their permissions are the
# intersection of the key grant and the owner's current tenant roles. # intersection of the key grant and the owner's current tenant roles.
api_key = authenticate_api_key(session, token) api_key = authenticate_api_key(session, token)
if api_key: if api_key is None:
user = session.get(User, api_key.user_id) return None
account = session.get(Account, user.account_id) if user else None user = session.get(User, api_key.user_id)
tenant = session.get(Tenant, api_key.tenant_id) account = session.get(Account, user.account_id) if user else None
if ( tenant = session.get(Tenant, api_key.tenant_id)
not user or not account or not tenant if (
or not user.is_active or not account.is_active or not tenant.is_active not user or not account or not tenant
or user.tenant_id != api_key.tenant_id or not user.is_active or not account.is_active or not tenant.is_active
): or user.tenant_id != api_key.tenant_id
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") ):
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory)) idm_assignments, idm_roles = _principal_idm_context(
user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles) session,
effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or []) user=user,
session.commit() account=account,
return _build_principal_ref( tenant_id=api_key.tenant_id,
session, idm_directory=idm_directory,
api_key=api_key, organization_directory=organization_directory,
account=account, )
user=user, authorization_context = collect_user_authorization_context(
tenant_id=api_key.tenant_id, session,
scopes=effective_scopes, user,
auth_method="api_key", account=account,
idm_assignments=idm_assignments, include_system=False,
identity_directory=identity_directory, extra_roles=idm_roles,
extra_roles=idm_roles, )
) effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
session.commit()
principal = _build_principal_ref(
session,
api_key=api_key,
account=account,
user=user,
tenant_id=api_key.tenant_id,
scopes=effective_scopes,
auth_method="api_key",
idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles,
authorization_context=authorization_context,
)
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
def _resolve_session_principal_ref(
request: Request,
session: Session,
*,
token: str,
source: str,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> PrincipalRef | None:
context = _resolve_session_principal_context(
request,
session,
token=token,
source=source,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
return context.principal if context is not None else None
def _resolve_session_principal_context(
request: Request,
session: Session,
*,
token: str,
source: str,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext | None:
auth_session = authenticate_session_token(session, token) auth_session = authenticate_session_token(session, token)
if auth_session: if auth_session is None:
user = session.get(User, auth_session.user_id) return None
account = session.get(Account, auth_session.account_id) user = session.get(User, auth_session.user_id)
tenant = session.get(Tenant, auth_session.tenant_id) account = session.get(Account, auth_session.account_id)
if ( tenant = session.get(Tenant, auth_session.tenant_id)
not user or not account or not tenant if (
or not user.is_active or not account.is_active or not tenant.is_active not user or not account or not tenant
or user.account_id != account.id or not user.is_active or not account.is_active or not tenant.is_active
or user.tenant_id != tenant.id or user.account_id != account.id
): or user.tenant_id != tenant.id
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal") ):
if source == "cookie" and _requires_csrf(request): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal")
header_token = request.headers.get("x-csrf-token") if source == "cookie":
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name) _verify_session_csrf(request, auth_session)
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token): idm_assignments, idm_roles = _principal_idm_context(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") session,
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id) user=user,
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory)) account=account,
scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles) tenant_id=user.tenant_id,
session.commit() idm_directory=idm_directory,
return _build_principal_ref( organization_directory=organization_directory,
session, )
auth_session=auth_session, authorization_context = collect_user_authorization_context(
account=account, session,
user=user, user,
tenant_id=user.tenant_id, account=account,
scopes=scopes, include_system=True,
auth_method="session", extra_roles=idm_roles,
idm_assignments=idm_assignments, )
identity_directory=identity_directory, scopes = authorization_context.scopes
extra_roles=idm_roles, session.commit()
) principal = _build_principal_ref(
session,
auth_session=auth_session,
account=account,
user=user,
tenant_id=user.tenant_id,
scopes=scopes,
auth_method="session",
idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles,
authorization_context=authorization_context,
include_system_roles=True,
)
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def _verify_session_csrf(request: Request, auth_session: AuthSession) -> None:
if not _requires_csrf(request):
return
header_token = request.headers.get("x-csrf-token")
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
def _principal_idm_context(
session: Session,
*,
user: User,
account: Account,
tenant_id: str,
idm_directory: IdmDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]:
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=tenant_id)
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
return idm_assignments, idm_roles
def _idm_assignments_for_account( def _idm_assignments_for_account(
@@ -217,13 +405,6 @@ def _idm_assignments_for_account(
return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id)) return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id))
def _scopes_with_extra_roles(scopes: list[str], roles: tuple[Role, ...]) -> list[str]:
merged = set(scopes)
for role in roles:
merged.update(role.permissions or [])
return expand_scopes(merged)
def _registry_from_request(request: Request) -> PlatformRegistry | None: def _registry_from_request(request: Request) -> PlatformRegistry | None:
registry = getattr(request.app.state, "govoplan_registry", None) registry = getattr(request.app.state, "govoplan_registry", None)
return registry if isinstance(registry, PlatformRegistry) else None return registry if isinstance(registry, PlatformRegistry) else None
@@ -326,19 +507,32 @@ def resolve_api_principal(
) -> ApiPrincipal: ) -> ApiPrincipal:
resolver = _principal_resolver_from_request(request) resolver = _principal_resolver_from_request(request)
permission_evaluator = _permission_evaluator_from_request(request) permission_evaluator = _permission_evaluator_from_request(request)
if isinstance(resolver, LegacyPrincipalResolver):
context = _resolve_legacy_principal_context(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
idm_directory=resolver._idm_directory,
identity_directory=resolver._identity_directory,
organization_directory=resolver._organization_directory,
)
api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator)
_enforce_maintenance_mode(session, api_principal)
return api_principal
if resolver is not None: if resolver is not None:
principal = resolver.resolve_request(request, session=session) principal = resolver.resolve_request(request, session=session)
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator) api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
_enforce_maintenance_mode(session, api_principal) _enforce_maintenance_mode(session, api_principal)
return api_principal return api_principal
principal = _resolve_legacy_principal_ref( context = _resolve_legacy_principal_context(
request, request,
session, session,
authorization=authorization, authorization=authorization,
x_api_key=x_api_key, x_api_key=x_api_key,
) )
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator) api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator)
_enforce_maintenance_mode(session, api_principal) _enforce_maintenance_mode(session, api_principal)
return api_principal return api_principal

View File

@@ -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.7", 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,

View File

@@ -0,0 +1,192 @@
"""v0.1.7 access baseline
Revision ID: 4a5b6c7d8e9f
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '4a5b6c7d8e9f'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('access_identities',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('external_subject', sa.String(length=255), nullable=True),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identities'))
)
op.create_index(op.f('ix_access_identities_external_subject'), 'access_identities', ['external_subject'], unique=False)
op.create_table('access_organization_units',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('parent_id', sa.String(length=36), nullable=True),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['access_organization_units.id'], name=op.f('fk_access_organization_units_parent_id_access_organization_units'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_organization_units_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_organization_units')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organization_units_tenant_slug')
)
op.create_index(op.f('ix_access_organization_units_parent_id'), 'access_organization_units', ['parent_id'], unique=False)
op.create_index(op.f('ix_access_organization_units_tenant_id'), 'access_organization_units', ['tenant_id'], unique=False)
op.create_table('access_external_function_role_assignments',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('source_module', sa.String(length=50), nullable=False),
sa.Column('function_id', sa.String(length=36), nullable=False),
sa.Column('role_id', sa.String(length=36), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_external_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_external_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_external_function_role_assignments')),
sa.UniqueConstraint('tenant_id', 'source_module', 'function_id', 'role_id', name='uq_external_function_role_assignments')
)
op.create_index(op.f('ix_access_external_function_role_assignments_function_id'), 'access_external_function_role_assignments', ['function_id'], unique=False)
op.create_index(op.f('ix_access_external_function_role_assignments_role_id'), 'access_external_function_role_assignments', ['role_id'], unique=False)
op.create_index(op.f('ix_access_external_function_role_assignments_source_module'), 'access_external_function_role_assignments', ['source_module'], unique=False)
op.create_index(op.f('ix_access_external_function_role_assignments_tenant_id'), 'access_external_function_role_assignments', ['tenant_id'], unique=False)
op.create_table('access_functions',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('delegable', sa.Boolean(), nullable=False),
sa.Column('act_in_place_allowed', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_functions_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_functions_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_functions')),
sa.UniqueConstraint('tenant_id', 'organization_unit_id', 'slug', name='uq_functions_tenant_ou_slug')
)
op.create_index(op.f('ix_access_functions_organization_unit_id'), 'access_functions', ['organization_unit_id'], unique=False)
op.create_index(op.f('ix_access_functions_tenant_id'), 'access_functions', ['tenant_id'], unique=False)
op.create_table('access_identity_account_links',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('identity_id', sa.String(length=36), nullable=False),
sa.Column('account_id', sa.String(length=36), nullable=False),
sa.Column('is_primary', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_identity_account_links_account_id_access_accounts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_identity_account_links_identity_id_access_identities'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identity_account_links')),
sa.UniqueConstraint('identity_id', 'account_id', name='uq_identity_account_links_identity_account')
)
op.create_index(op.f('ix_access_identity_account_links_account_id'), 'access_identity_account_links', ['account_id'], unique=False)
op.create_index(op.f('ix_access_identity_account_links_identity_id'), 'access_identity_account_links', ['identity_id'], unique=False)
op.create_index('uq_identity_account_links_primary_account', 'access_identity_account_links', ['account_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
op.create_index('uq_identity_account_links_primary_identity', 'access_identity_account_links', ['identity_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
op.create_table('access_function_assignments',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('account_id', sa.String(length=36), nullable=False),
sa.Column('identity_id', sa.String(length=36), nullable=True),
sa.Column('function_id', sa.String(length=36), nullable=False),
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_account_id_access_accounts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['acting_for_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_acting_for_account_id_access_accounts'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_assignments_function_id_access_functions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_function_assignments_identity_id_access_identities'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_function_assignments_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_assignments_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_assignments')),
sa.UniqueConstraint('tenant_id', 'account_id', 'function_id', 'organization_unit_id', name='uq_function_assignments_account_scope')
)
op.create_index(op.f('ix_access_function_assignments_account_id'), 'access_function_assignments', ['account_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_acting_for_account_id'), 'access_function_assignments', ['acting_for_account_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_delegated_from_assignment_id'), 'access_function_assignments', ['delegated_from_assignment_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_function_id'), 'access_function_assignments', ['function_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_identity_id'), 'access_function_assignments', ['identity_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_organization_unit_id'), 'access_function_assignments', ['organization_unit_id'], unique=False)
op.create_index(op.f('ix_access_function_assignments_tenant_id'), 'access_function_assignments', ['tenant_id'], unique=False)
op.create_table('access_function_role_assignments',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('function_id', sa.String(length=36), nullable=False),
sa.Column('role_id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_role_assignments_function_id_access_functions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_role_assignments')),
sa.UniqueConstraint('tenant_id', 'function_id', 'role_id', name='uq_function_role_assignments')
)
op.create_index(op.f('ix_access_function_role_assignments_function_id'), 'access_function_role_assignments', ['function_id'], unique=False)
op.create_index(op.f('ix_access_function_role_assignments_role_id'), 'access_function_role_assignments', ['role_id'], unique=False)
op.create_index(op.f('ix_access_function_role_assignments_tenant_id'), 'access_function_role_assignments', ['tenant_id'], unique=False)
op.create_table('access_function_delegations',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('function_assignment_id', sa.String(length=36), nullable=False),
sa.Column('delegator_account_id', sa.String(length=36), nullable=False),
sa.Column('delegate_account_id', sa.String(length=36), nullable=False),
sa.Column('mode', sa.String(length=30), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['delegate_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegate_account_id_access_accounts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['delegator_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegator_account_id_access_accounts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['function_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_delegations_function_assignment_id_access_function_assignments'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_delegations_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_delegations')),
sa.UniqueConstraint('tenant_id', 'function_assignment_id', 'delegate_account_id', 'mode', name='uq_function_delegations_assignment_delegate_mode')
)
op.create_index(op.f('ix_access_function_delegations_delegate_account_id'), 'access_function_delegations', ['delegate_account_id'], unique=False)
op.create_index(op.f('ix_access_function_delegations_delegator_account_id'), 'access_function_delegations', ['delegator_account_id'], unique=False)
op.create_index(op.f('ix_access_function_delegations_function_assignment_id'), 'access_function_delegations', ['function_assignment_id'], unique=False)
op.create_index(op.f('ix_access_function_delegations_revoked_at'), 'access_function_delegations', ['revoked_at'], unique=False)
op.create_index(op.f('ix_access_function_delegations_tenant_id'), 'access_function_delegations', ['tenant_id'], unique=False)
def downgrade() -> None:
op.drop_table('access_function_delegations')
op.drop_table('access_function_role_assignments')
op.drop_table('access_function_assignments')
op.drop_table('access_identity_account_links')
op.drop_table('access_functions')
op.drop_table('access_external_function_role_assignments')
op.drop_table('access_organization_units')
op.drop_table('access_identities')

View 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"]

View File

@@ -3,71 +3,26 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
from govoplan_access.backend.permissions.evaluator import scopes_grant as _scopes_grant
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
from govoplan_core.security.module_permissions import compatible_required_scopes from govoplan_core.security.module_permissions import compatible_required_scopes
from govoplan_core.security.permissions import ALL_PERMISSIONS as CORE_LEGACY_PERMISSION_DEFINITIONS
from govoplan_core.security.permissions import PermissionDefinition as CoreLegacyPermissionDefinition
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = { def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition:
"campaign:write": frozenset({ parts = permission.scope.split(":", 2)
"campaign:create", if len(parts) == 2:
"campaign:update", module_id, action = parts
"campaign:copy", resource = module_id
"campaign:archive", else:
"campaign:delete", module_id, resource, action = parts
"campaign:share",
"recipients:read",
"recipients:write",
"recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read",
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:groups:read",
"admin:groups:write",
"admin:groups:manage_members",
"admin:roles:read",
"admin:roles:write",
"admin:roles:assign",
}),
"admin:users:write": frozenset({
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:roles:assign",
"admin:groups:manage_members",
}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({
"admin:settings:read",
"admin:settings:write",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({
"system:access:assign",
"system:roles:assign",
"system:accounts:create",
"system:accounts:update",
"system:accounts:suspend",
}),
}
def _permission(scope: str, label: str, description: str, category: str, level: PermissionLevel) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(
scope=scope, scope=permission.scope,
label=label, label=permission.label,
description=description, description=permission.description,
category=category, category=permission.category,
level=level, level=permission.level, # type: ignore[arg-type]
module_id=module_id, module_id=module_id,
resource=resource, resource=resource,
action=action, action=action,
@@ -75,43 +30,9 @@ def _permission(scope: str, label: str, description: str, category: str, level:
) )
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = ( LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple(
_permission("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration", "tenant"), _legacy_permission(permission)
_permission("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration", "tenant"), for permission in CORE_LEGACY_PERMISSION_DEFINITIONS
_permission("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration", "tenant"),
_permission("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration", "tenant"),
_permission("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration", "tenant"),
_permission("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration", "tenant"),
_permission("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration", "tenant"),
_permission("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration", "tenant"),
_permission("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration", "tenant"),
_permission("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration", "tenant"),
_permission("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration", "tenant"),
_permission("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration", "tenant"),
_permission("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration", "tenant"),
_permission("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
_permission("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
_permission("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration", "tenant"),
_permission("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration", "tenant"),
_permission("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"),
_permission("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
_permission("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
_permission("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
_permission("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
_permission("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
_permission("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
_permission("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"),
_permission("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"),
_permission("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"),
_permission("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"),
_permission("system:access:read", "View system access", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"),
_permission("system:access:assign", "Assign system access", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"),
_permission("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"),
_permission("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"),
_permission("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"),
_permission("system:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "System administration", "system"),
_permission("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"),
_permission("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"),
) )
@@ -146,12 +67,12 @@ def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...]
return tuple(template for template in role_templates() if template.level == level) return tuple(template for template in role_templates() if template.level == level)
def scope_grants(granted: str, required: str) -> bool: def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
catalog = permission_map(include_legacy=True) catalog = catalog if catalog is not None else permission_map(include_legacy=True)
if _scope_grants(granted, required, catalog=catalog): if _scope_grants(granted, required, catalog=catalog):
return True return True
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()): for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
if scope_grants(alias, required): if scope_grants(alias, required, catalog=catalog):
return True return True
return any( return any(
_scope_grants(granted, alias, catalog=catalog) _scope_grants(granted, alias, catalog=catalog)
@@ -160,8 +81,9 @@ def scope_grants(granted: str, required: str) -> bool:
) )
def scopes_grant(scopes: Iterable[str], required: str) -> bool: def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
return any(scope_grants(scope, required) for scope in scopes) catalog = catalog if catalog is not None else permission_map(include_legacy=True)
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]: def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
@@ -173,12 +95,17 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
expanded.add(scope) expanded.add(scope)
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()): for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
expanded.add(alias) expanded.add(alias)
matched = {candidate for candidate in catalog if scope_grants(scope, candidate)} matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
expanded.update(matched) expanded.update(matched)
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)
@@ -186,7 +113,8 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]: def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
candidates = _effective_permission_candidates(level=level) candidates = _effective_permission_candidates(level=level)
granted = list(scopes) granted = list(scopes)
return {scope for scope in candidates if scopes_grant(granted, scope)} catalog = permission_map(include_legacy=True)
return {scope for scope in candidates if scopes_grant(granted, scope, catalog=catalog)}
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int: def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
@@ -207,7 +135,11 @@ def _effective_permission_candidates(*, level: PermissionLevel | None = None) ->
continue continue
if level is not None and definition.level != level: if level is not None and definition.level != level:
continue continue
if any(scope_grants(active_scope, scope) or scope_grants(scope, active_scope) for active_scope in active_scopes): if any(
scope_grants(active_scope, scope, catalog=full_catalog)
or scope_grants(scope, active_scope, catalog=full_catalog)
for active_scope in active_scopes
):
continue continue
candidates.add(scope) candidates.add(scope)
return candidates return candidates
@@ -231,7 +163,7 @@ def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> li
expanded.add(alias) expanded.add(alias)
continue continue
if scope.endswith(":*"): if scope.endswith(":*"):
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate)} matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate, catalog=catalog)}
if matching: if matching:
expanded.add(scope) expanded.add(scope)
continue continue
@@ -272,8 +204,9 @@ def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]: def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
user = list(user_scopes) user = list(user_scopes)
key = list(key_scopes) key = list(key_scopes)
tenant_scopes = {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == "tenant"} catalog = permission_map(include_legacy=True)
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope) and scopes_grant(key, scope)} tenant_scopes = {scope for scope, definition in catalog.items() if definition.level == "tenant"}
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope, catalog=catalog) and scopes_grant(key, scope, catalog=catalog)}
user_raw = set(expand_scopes(user)) user_raw = set(expand_scopes(user))
key_raw = set(expand_scopes(key)) key_raw = set(expand_scopes(key))
allowed.update( allowed.update(

View 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,
)

View File

@@ -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)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from collections.abc import Iterable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -18,8 +19,8 @@ from govoplan_access.backend.db.models import (
UserGroupMembership, UserGroupMembership,
UserRoleAssignment, UserRoleAssignment,
) )
from govoplan_access.backend.semantic import 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
@@ -33,6 +34,16 @@ class CreatedSession:
csrf_token: str csrf_token: str
@dataclass(slots=True)
class UserAuthorizationContext:
tenant_roles: list[Role]
system_roles: list[Role]
groups: list[Group]
function_assignment_ids: tuple[str, ...]
function_delegation_ids: tuple[str, ...]
scopes: list[str]
def generate_session_token() -> str: def generate_session_token() -> str:
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES) return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
@@ -172,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())
@@ -199,16 +212,115 @@ def collect_user_groups(session: Session, user: User) -> list[Group]:
) )
def collect_user_authorization_context(
session: Session,
user: User,
*,
account: Account | None = None,
include_system: bool = True,
extra_roles: Iterable[Role] = (),
) -> UserAuthorizationContext:
account = account or user.account
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
groups = collect_user_groups(session, user)
group_ids = [group.id for group in groups]
if group_ids:
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.filter(
GroupRoleAssignment.tenant_id == user.tenant_id,
GroupRoleAssignment.group_id.in_(sorted(group_ids)),
Role.tenant_id == user.tenant_id,
)
.order_by(Role.name.asc())
.all()
)
for role in group_roles:
roles_by_id[role.id] = role
function_context = collect_function_authorization_context(session, user)
for role in function_context.roles:
roles_by_id[role.id] = role
for role in extra_roles:
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())
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
default_slugs = _default_authenticated_slugs()
scopes = {
scope
for role in tenant_roles
if role.slug not in default_slugs
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(
tenant_roles=tenant_roles,
system_roles=system_roles,
groups=groups,
function_assignment_ids=function_context.assignment_ids,
function_delegation_ids=function_context.delegation_ids,
scopes=expand_scopes(scopes),
)
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)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -23,6 +24,13 @@ from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, Organizati
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
@dataclass(slots=True)
class FunctionAuthorizationContext:
assignment_ids: tuple[str, ...]
delegation_ids: tuple[str, ...]
roles: tuple[Role, ...]
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None: def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
return ( return (
session.query(Identity) session.query(Identity)
@@ -169,6 +177,56 @@ def collect_function_roles(session: Session, user: User) -> list[Role]:
) )
def collect_function_authorization_context(session: Session, user: User) -> FunctionAuthorizationContext:
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
delegations = active_function_delegations_for_account(
session,
user.account_id,
tenant_id=user.tenant_id,
modes=("delegate", "act_in_place"),
)
direct_assignment_ids = {assignment.id for assignment in assignments}
delegated_role_assignment_ids = {
delegation.function_assignment_id
for delegation in delegations
if delegation.mode == "delegate"
}
role_assignment_ids = direct_assignment_ids | delegated_role_assignment_ids
function_ids = {assignment.function_id for assignment in assignments}
missing_role_assignment_ids = delegated_role_assignment_ids - direct_assignment_ids
if missing_role_assignment_ids:
function_ids.update(
row[0]
for row in session.query(FunctionAssignment.function_id)
.filter(
FunctionAssignment.tenant_id == user.tenant_id,
FunctionAssignment.id.in_(sorted(missing_role_assignment_ids)),
)
.all()
)
roles: tuple[Role, ...] = ()
if function_ids:
roles = tuple(
session.query(Role)
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
.filter(
FunctionRoleAssignment.tenant_id == user.tenant_id,
FunctionRoleAssignment.function_id.in_(sorted(function_ids)),
Role.tenant_id == user.tenant_id,
)
.order_by(Role.name.asc())
.all()
)
return FunctionAuthorizationContext(
assignment_ids=tuple(sorted(role_assignment_ids)),
delegation_ids=tuple(sorted(delegation.id for delegation in delegations)),
roles=roles,
)
def collect_external_function_roles( def collect_external_function_roles(
session: Session, session: Session,
user: User, user: User,

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.api.v1.admin_common import (
_accounts_by_user_id,
_group_member_ids_by_group_id,
_groups_by_user_id,
_roles_by_group_id,
_roles_by_user_id,
_tenant_role_assignment_counts,
)
from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
from govoplan_core.db.base import Base
class AdminBatchHelperTests(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()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_access_admin_batch_maps_related_rows(self) -> None:
session = self.session
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)
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
role = Role(id="role-1", tenant_id="tenant-1", slug="reader", name="Reader", permissions=["admin:users:read"])
session.add_all([account, user, group, role])
session.flush()
session.add(UserGroupMembership(tenant_id="tenant-1", user_id=user.id, group_id=group.id))
session.add(UserRoleAssignment(tenant_id="tenant-1", user_id=user.id, role_id=role.id))
session.add(GroupRoleAssignment(tenant_id="tenant-1", group_id=group.id, role_id=role.id))
session.commit()
accounts_by_user = _accounts_by_user_id(session, [user.id])
group_member_ids = _group_member_ids_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
groups_by_user = _groups_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
roles_by_group = _roles_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
roles_by_user = _roles_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
role_counts = _tenant_role_assignment_counts(session, [role.id])
self.assertEqual(accounts_by_user[user.id].id, account.id)
self.assertEqual(group_member_ids, {group.id: [user.id]})
self.assertEqual([item.id for item in groups_by_user[user.id]], [group.id])
self.assertEqual([item.id for item in roles_by_group[group.id]], [role.id])
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
self.assertEqual(role_counts, {role.id: (1, 1)})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import unittest
from fastapi import HTTPException
from govoplan_access.backend.api.v1.routes import (
_decode_full_delta_cursor,
_encode_full_delta_cursor,
_full_delta_page,
_page_query,
)
class FakeQuery:
def __init__(self, rows: list[int]) -> None:
self._rows = rows
self._offset = 0
self._limit: int | None = None
def order_by(self, *_args: object) -> FakeQuery:
return self
def count(self) -> int:
return len(self._rows)
def offset(self, value: int) -> FakeQuery:
clone = FakeQuery(self._rows)
clone._offset = value
clone._limit = self._limit
return clone
def limit(self, value: int) -> FakeQuery:
clone = FakeQuery(self._rows)
clone._offset = self._offset
clone._limit = value
return clone
def all(self) -> list[int]:
end = None if self._limit is None else self._offset + self._limit
return self._rows[self._offset:end]
class AdminPaginationTests(unittest.TestCase):
def test_page_query_returns_page_and_metadata(self) -> None:
rows, metadata = _page_query(FakeQuery(list(range(7))), page=2, page_size=3)
self.assertEqual(rows, [3, 4, 5])
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
def test_full_delta_page_returns_cursor_until_last_page(self) -> None:
rows, metadata, watermark, has_more = _full_delta_page(
FakeQuery(list(range(7))),
page=2,
page_size=3,
scope="users",
snapshot_sequence=42,
)
self.assertEqual(rows, [3, 4, 5])
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
self.assertEqual(watermark, "full:users:3:42")
self.assertTrue(has_more)
def test_full_delta_page_returns_sequence_watermark_on_last_page(self) -> None:
rows, metadata, watermark, has_more = _full_delta_page(
FakeQuery(list(range(7))),
page=3,
page_size=3,
scope="users",
snapshot_sequence=42,
)
self.assertEqual(rows, [6])
self.assertEqual(metadata, {"total": 7, "page": 3, "page_size": 3, "pages": 3})
self.assertEqual(watermark, "seq:42")
self.assertFalse(has_more)
def test_full_delta_cursor_round_trips_and_rejects_wrong_scope(self) -> None:
cursor = _encode_full_delta_cursor("users", page=3, snapshot_sequence=42)
self.assertEqual(_decode_full_delta_cursor(cursor, scope="users"), (3, 42))
self.assertIsNone(_decode_full_delta_cursor("seq:42", scope="users"))
with self.assertRaises(HTTPException):
_decode_full_delta_cursor(cursor, scope="groups")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import unittest
from typing import Iterable
from fastapi import HTTPException
from starlette.requests import Request
from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref
from govoplan_core.settings import settings
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
return Request(
{
"type": "http",
"method": method,
"path": "/",
"headers": [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers],
}
)
class AuthDependencyTests(unittest.TestCase):
def test_extract_token_prefers_explicit_api_key(self) -> None:
request = request_for(headers=[("authorization", "Bearer session-token")])
self.assertEqual(_extract_token(request, "Bearer session-token", "api-key-token"), ("api-key-token", "api_key"))
def test_extract_token_supports_bearer_and_cookie_sources(self) -> None:
self.assertEqual(_extract_token(request_for(), "Bearer session-token", None), ("session-token", "bearer"))
cookie_request = request_for(headers=[("cookie", f"{settings.auth_session_cookie_name}=cookie-token")])
self.assertEqual(_extract_token(cookie_request, None, None), ("cookie-token", "cookie"))
def test_requires_csrf_only_for_mutating_methods(self) -> None:
self.assertFalse(_requires_csrf(request_for(method="GET")))
self.assertTrue(_requires_csrf(request_for(method="POST")))
def test_legacy_principal_resolver_rejects_missing_token_before_db_lookup(self) -> None:
with self.assertRaises(HTTPException) as raised:
_resolve_legacy_principal_ref(request_for(), None, authorization=None, x_api_key=None) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Missing API key or session token")
if __name__ == "__main__":
unittest.main()

View 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()

View 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()

View 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()

View File

@@ -15,8 +15,8 @@ class OptionalTenancyContractTests(unittest.TestCase):
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.6", dependencies) self.assertIn("govoplan-core>=0.1.11", dependencies)
self.assertNotIn("govoplan-tenancy>=0.1.6", 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))
def test_tenancy_is_declared_as_optional_module_integration(self) -> None: def test_tenancy_is_declared_as_optional_module_integration(self) -> None:

View 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()

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import unittest
from govoplan_access.backend.permissions import catalog as access_catalog
from govoplan_core.security import permissions as core_permissions
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
class PermissionCatalogContractTests(unittest.TestCase):
def test_access_reuses_core_legacy_scope_aliases(self) -> None:
self.assertIs(access_catalog.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
self.assertIs(core_permissions.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
def test_legacy_alias_grants_match_in_core_and_access(self) -> None:
for legacy_scope, aliases in LEGACY_SCOPE_ALIASES.items():
for alias in aliases:
self.assertTrue(core_permissions.scope_grants(legacy_scope, alias))
self.assertTrue(access_catalog.scope_grants(legacy_scope, alias))
def test_access_legacy_catalog_includes_non_access_platform_scopes(self) -> None:
scopes = {permission.scope for permission in access_catalog.permission_catalog(include_legacy=True)}
self.assertIn("campaign:queue", scopes)
self.assertIn("files:upload", 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__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.7", "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.7", "@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",

View File

@@ -1,47 +1,24 @@
import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui"; import type {
import { apiFetch } from "@govoplan/core-webui"; AccessDecisionProvenanceItem as CoreAccessDecisionProvenanceItem,
ApiSettings,
export type PermissionItem = { DeltaDeletedItem,
scope: string; PrivacyRetentionPolicy,
label: string; ResourceAccessExplanationOptions,
description: string; ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
category: string; TenantAdminItem
level: "tenant" | "system"; } from "@govoplan/core-webui";
system_template_id?: string | null; import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
system_required?: boolean; export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
}; export type {
AdminOverview,
export type AdminOverview = { PrivacyRetentionLimitPermissionPatch,
active_tenant_id: string; PrivacyRetentionLimitPermissions,
active_tenant_name: string; PrivacyRetentionPolicy,
tenant_count?: number | null; PrivacyRetentionPolicyFieldKey,
system_account_count?: number | null; PrivacyRetentionPolicyPatch,
system_group_template_count?: number | null; PermissionItem,
system_role_template_count?: number | null; TenantAdminItem
user_count: number; } from "@govoplan/core-webui";
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = { export type TenantOwnerCandidate = {
account_id: string; account_id: string;
@@ -132,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>;
}; };
@@ -167,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;
@@ -195,28 +161,6 @@ export type SystemMembershipDraft = {
is_last_active_owner?: boolean; is_last_active_owner?: boolean;
}; };
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type SystemSettingsItem = { export type SystemSettingsItem = {
default_locale: string; default_locale: string;
allow_tenant_custom_groups: boolean; allow_tenant_custom_groups: boolean;
@@ -317,36 +261,18 @@ export type TenantSettingsDeltaResponse = {
} & DeltaResponseFields; } & DeltaResponseFields;
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
const params = new URLSearchParams(); return apiQuery(options);
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
return params.toString() ? `?${params.toString()}` : "";
} }
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> { export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
const suffix = deltaSuffix(options); const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`); return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
} }
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> { export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates"); return apiGetList<TenantOwnerCandidate, "accounts">(settings, "/api/v1/admin/tenants/owner-candidates", "accounts");
return response.accounts;
} }
export function createTenant(settings: ApiSettings, payload: { export function createTenant(settings: ApiSettings, payload: {
@@ -427,21 +353,13 @@ 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> {
const params = new URLSearchParams({ return fetchCoreResourceAccessExplanation<UserAdminItem, AccessDecisionProvenanceItem>(settings, options);
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action
});
if (options.tenantId) params.set("tenant_id", options.tenantId);
return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
} }
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> { export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups"); return apiGetList<GroupSummary, "groups">(settings, "/api/v1/admin/groups", "groups");
return response.groups;
} }
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> { export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
@@ -471,8 +389,7 @@ export function updateGroup(settings: ApiSettings, groupId: string, payload: Par
} }
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> { export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles"); return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/roles", "roles");
return response.roles;
} }
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> { export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
@@ -528,8 +445,7 @@ export function deleteExternalFunctionRoleMapping(settings: ApiSettings, mapping
} }
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> { export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/system/roles", "roles");
return response.roles;
} }
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> { export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
@@ -587,20 +503,15 @@ export function updateSystemAccountRoles(settings: ApiSettings, accountId: strin
} }
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> { export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams(); return apiGetList<ApiKeyAdminItem, "api_keys">(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined });
if (includeRevoked) params.set("include_revoked", "true");
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
return response.api_keys;
} }
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> { export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
const params = new URLSearchParams(); return apiFetch(settings, apiPath("/api/v1/admin/api-keys/delta", {
if (includeRevoked) params.set("include_revoked", "true"); include_revoked: includeRevoked ? true : undefined,
if (options.since) params.set("since", options.since); since: options.since,
if (options.limit) params.set("limit", String(options.limit)); limit: options.limit
const suffix = params.toString() ? `?${params.toString()}` : ""; }));
return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`);
} }
export function createApiKey(settings: ApiSettings, payload: { export function createApiKey(settings: ApiSettings, payload: {
@@ -653,9 +564,7 @@ export function updateSystemSettings(settings: ApiSettings, payload: SystemSetti
} }
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> { export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
} }
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> { export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {

View File

@@ -5,11 +5,12 @@ import type {
AdminSectionsUiCapability, AdminSectionsUiCapability,
ApiSettings, ApiSettings,
AuthInfo, AuthInfo,
AuthUpdate,
FilesConnectorsUiCapability, FilesConnectorsUiCapability,
MailProfilesUiCapability, MailProfilesUiCapability,
OrganizationFunctionPickerUiCapability OrganizationFunctionPickerUiCapability
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { fetchMe } from "@govoplan/core-webui"; import { fetchShellAuth } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui"; import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui"; import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
@@ -63,7 +64,7 @@ export default function AdminPage({
}: { }: {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo; auth: AuthInfo;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
}) { }) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors"); const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
@@ -131,13 +132,9 @@ export default function AdminPage({
}, [requestedSection, available, fallbackSection]); }, [requestedSection, available, fallbackSection]);
async function refreshAuth() { async function refreshAuth() {
onAuthChange(await fetchMe(settings)); onAuthChange(await fetchShellAuth(settings));
} }
useEffect(() => {
void refreshAuth().catch(() => undefined);
}, [settings.accessToken, settings.apiBaseUrl]);
if (!hasAnyScope(auth, adminReadScopes)) { if (!hasAnyScope(auth, adminReadScopes)) {
return ( return (
<div className="content-pad"> <div className="content-pad">

View File

@@ -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>

View File

@@ -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]

View File

@@ -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 (

View File

@@ -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>}>

View File

@@ -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]);

View File

@@ -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: "",
@@ -83,8 +83,17 @@ export default function SystemUsersPanel({
let hasMore = false; let hasMore = false;
do { do {
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark }); const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
nextAccounts = response.full ? response.accounts : mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts }); const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
nextRoles = response.full ? response.roles : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles }); nextAccounts = response.full
? continuingFullSnapshot
? mergeDeltaRows(nextAccounts, response.accounts, [], (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts })
: response.accounts
: mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
nextRoles = response.full
? continuingFullSnapshot
? mergeDeltaRows(nextRoles, response.roles, [], (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles })
: response.roles
: mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
nextWatermark = response.watermark ?? null; nextWatermark = response.watermark ?? null;
hasMore = response.has_more; hasMore = response.has_more;
} while (hasMore); } while (hasMore);
@@ -140,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: [] }
)
})); }));
} }
@@ -206,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 (
@@ -244,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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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>

View File

@@ -24,7 +24,12 @@ export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>
do { do {
const response = await fetchDelta(nextWatermark); const response = await fetchDelta(nextWatermark);
const rows = rowsFromResponse(response); const rows = rowsFromResponse(response);
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort }); const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
merged = response.full
? continuingFullSnapshot
? mergeDeltaRows(merged, rows, [], getKey, { deletedResourceType, sort })
: rows
: mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
nextWatermark = response.watermark ?? null; nextWatermark = response.watermark ?? null;
hasMore = response.has_more; hasMore = response.has_more;
} while (hasMore); } while (hasMore);

View File

@@ -17,7 +17,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.add_api_key.725d9988": "Add API key", "i18n:govoplan-access.add_api_key.725d9988": "Add API key",
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account", "i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
"i18n:govoplan-access.add_group.2fca464f": "Add group", "i18n:govoplan-access.add_group.2fca464f": "Add group",
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Add function role mapping", "i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Add function mapping",
"i18n:govoplan-access.add_role.d8d5d55c": "Add role", "i18n:govoplan-access.add_role.d8d5d55c": "Add role",
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role", "i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Add tenant user", "i18n:govoplan-access.add_tenant_user.36f37ce7": "Add tenant user",
@@ -55,7 +55,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.create_api_key.d7b30388": "Create API key", "i18n:govoplan-access.create_api_key.d7b30388": "Create API key",
"i18n:govoplan-access.create_global_account.e821f016": "Create global account", "i18n:govoplan-access.create_global_account.e821f016": "Create global account",
"i18n:govoplan-access.create_group.5a0b1c17": "Create group", "i18n:govoplan-access.create_group.5a0b1c17": "Create group",
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Create function role mapping", "i18n:govoplan-access.create_function_role_mapping.3718168d": "Create function mapping",
"i18n:govoplan-access.create_key.e028cb09": "Create key", "i18n:govoplan-access.create_key.e028cb09": "Create key",
"i18n:govoplan-access.create_role.db859bad": "Create role", "i18n:govoplan-access.create_role.db859bad": "Create role",
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role", "i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
@@ -80,7 +80,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}", "i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-access.default_locale.b99d021f": "Default locale", "i18n:govoplan-access.default_locale.b99d021f": "Default locale",
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role", "i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Delete function role mapping", "i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Delete function mapping",
"i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Delete the mapping for {value0}? Accepted assignments will no longer grant the mapped role.", "i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Delete the mapping for {value0}? Accepted assignments will no longer grant the mapped role.",
"i18n:govoplan-access.delete_mapping.0d27d92a": "Delete mapping", "i18n:govoplan-access.delete_mapping.0d27d92a": "Delete mapping",
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role", "i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
@@ -95,7 +95,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.dry_run.485a3d15": "Dry run", "i18n:govoplan-access.dry_run.485a3d15": "Dry run",
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account", "i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
"i18n:govoplan-access.edit_group.edb57d8e": "Edit group", "i18n:govoplan-access.edit_group.edb57d8e": "Edit group",
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Edit function role mapping", "i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Edit function mapping",
"i18n:govoplan-access.edit_role.61dd63e9": "Edit role", "i18n:govoplan-access.edit_role.61dd63e9": "Edit role",
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role", "i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
"i18n:govoplan-access.edit_tenant_user.99121a61": "Edit tenant user", "i18n:govoplan-access.edit_tenant_user.99121a61": "Edit tenant user",
@@ -108,17 +108,19 @@ 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",
"i18n:govoplan-access.function_fact.4f7435e4": "Function fact", "i18n:govoplan-access.function_fact.4f7435e4": "Function fact",
"i18n:govoplan-access.function_facts.848b32cc": "Function facts", "i18n:govoplan-access.function_facts.848b32cc": "Function facts",
"i18n:govoplan-access.function_id.e5e08937": "Function ID", "i18n:govoplan-access.function_id.e5e08937": "Function ID",
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Function role mapping created.", "i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Function mapping created.",
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Function role mapping deleted.", "i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Function mapping deleted.",
"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 role mapping updated.", "i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function role 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.",
@@ -186,7 +188,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.", "i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "No assignable roles exist.", "i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "No assignable roles exist.",
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry", "i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "No function role mappings found.", "i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "No function mappings found.",
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "No function facts found.", "i18n:govoplan-access.no_function_facts_found.b2ecee17": "No function facts found.",
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.", "i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.", "i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.",
@@ -301,7 +303,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles", "i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy", "i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention", "i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
"i18n:govoplan-access.tenant_role_templates": "Tenant role templates", "i18n:govoplan-access.tenant_role_templates": "Role templates",
"i18n:govoplan-access.tenant_roles.51aca82d": "Tenant roles", "i18n:govoplan-access.tenant_roles.51aca82d": "Tenant roles",
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.", "i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
"i18n:govoplan-access.tenant_user_details.fbab9079": "Tenant user details", "i18n:govoplan-access.tenant_user_details.fbab9079": "Tenant user details",
@@ -365,7 +367,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.add_api_key.725d9988": "Add API key", "i18n:govoplan-access.add_api_key.725d9988": "Add API key",
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account", "i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
"i18n:govoplan-access.add_group.2fca464f": "Gruppe hinzufügen", "i18n:govoplan-access.add_group.2fca464f": "Gruppe hinzufügen",
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Funktions-Rollenzuordnung hinzufügen", "i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Funktionszuordnung hinzufügen",
"i18n:govoplan-access.add_role.d8d5d55c": "Rolle hinzufügen", "i18n:govoplan-access.add_role.d8d5d55c": "Rolle hinzufügen",
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role", "i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Mandantenbenutzer hinzufügen", "i18n:govoplan-access.add_tenant_user.36f37ce7": "Mandantenbenutzer hinzufügen",
@@ -403,7 +405,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.create_api_key.d7b30388": "API-Schlüssel erstellen", "i18n:govoplan-access.create_api_key.d7b30388": "API-Schlüssel erstellen",
"i18n:govoplan-access.create_global_account.e821f016": "Create global account", "i18n:govoplan-access.create_global_account.e821f016": "Create global account",
"i18n:govoplan-access.create_group.5a0b1c17": "Gruppe erstellen", "i18n:govoplan-access.create_group.5a0b1c17": "Gruppe erstellen",
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Funktions-Rollenzuordnung erstellen", "i18n:govoplan-access.create_function_role_mapping.3718168d": "Funktionszuordnung erstellen",
"i18n:govoplan-access.create_key.e028cb09": "Create key", "i18n:govoplan-access.create_key.e028cb09": "Create key",
"i18n:govoplan-access.create_role.db859bad": "Rolle erstellen", "i18n:govoplan-access.create_role.db859bad": "Rolle erstellen",
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role", "i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
@@ -428,7 +430,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}", "i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-access.default_locale.b99d021f": "Standardsprache", "i18n:govoplan-access.default_locale.b99d021f": "Standardsprache",
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role", "i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Funktions-Rollenzuordnung löschen", "i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Funktionszuordnung löschen",
"i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Zuordnung für {value0} löschen? Akzeptierte Zuweisungen gewähren die zugeordnete Rolle dann nicht mehr.", "i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Zuordnung für {value0} löschen? Akzeptierte Zuweisungen gewähren die zugeordnete Rolle dann nicht mehr.",
"i18n:govoplan-access.delete_mapping.0d27d92a": "Zuordnung löschen", "i18n:govoplan-access.delete_mapping.0d27d92a": "Zuordnung löschen",
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role", "i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
@@ -443,7 +445,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf", "i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf",
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account", "i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
"i18n:govoplan-access.edit_group.edb57d8e": "Gruppe bearbeiten", "i18n:govoplan-access.edit_group.edb57d8e": "Gruppe bearbeiten",
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Funktions-Rollenzuordnung bearbeiten", "i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Funktionszuordnung bearbeiten",
"i18n:govoplan-access.edit_role.61dd63e9": "Rolle bearbeiten", "i18n:govoplan-access.edit_role.61dd63e9": "Rolle bearbeiten",
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role", "i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
"i18n:govoplan-access.edit_tenant_user.99121a61": "Mandantenbenutzer bearbeiten", "i18n:govoplan-access.edit_tenant_user.99121a61": "Mandantenbenutzer bearbeiten",
@@ -456,17 +458,19 @@ 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",
"i18n:govoplan-access.function_fact.4f7435e4": "Funktionsfakt", "i18n:govoplan-access.function_fact.4f7435e4": "Funktionsfakt",
"i18n:govoplan-access.function_facts.848b32cc": "Funktionsfakten", "i18n:govoplan-access.function_facts.848b32cc": "Funktionsfakten",
"i18n:govoplan-access.function_id.e5e08937": "Funktions-ID", "i18n:govoplan-access.function_id.e5e08937": "Funktions-ID",
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Funktions-Rollenzuordnung erstellt.", "i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Funktionszuordnung erstellt.",
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Funktions-Rollenzuordnung gelöscht.", "i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Funktionszuordnung gelöscht.",
"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": "Funktions-Rollenzuordnung aktualisiert.", "i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktions-Rollenzuordnungen", "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.",
@@ -534,7 +538,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.", "i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.", "i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.",
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry", "i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "Keine Funktions-Rollenzuordnungen gefunden.", "i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "Keine Funktionszuordnungen gefunden.",
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "Keine Funktionsfakten gefunden.", "i18n:govoplan-access.no_function_facts_found.b2ecee17": "Keine Funktionsfakten gefunden.",
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.", "i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.", "i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.",
@@ -649,7 +653,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles", "i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy", "i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention", "i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
"i18n:govoplan-access.tenant_role_templates": "Mandantenrollenvorlagen", "i18n:govoplan-access.tenant_role_templates": "Rollenvorlagen",
"i18n:govoplan-access.tenant_roles.51aca82d": "Mandantenrollen", "i18n:govoplan-access.tenant_roles.51aca82d": "Mandantenrollen",
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.", "i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
"i18n:govoplan-access.tenant_user_details.fbab9079": "Mandantenbenutzerdetails", "i18n:govoplan-access.tenant_user_details.fbab9079": "Mandantenbenutzerdetails",

View File

@@ -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";